# -*- coding: utf-8 -*- """ oc-session-sync.py - 对话数据同步: 本机(Win) -> WSL。 收尸链路 step 7 调用。设计(2026-10-02 首版; 2026-10-12 重建, 适配 2.0.27 schema): - 幂等: .oc_sync_state.json 存 {session_id: time_updated} 快照, 无变化秒退。 - 并集语义: 绝不删 WSL 数据, 只补缺: * WSL 缺的 session -> 整行插 + 其全部 message/part * 两边都有但本机 time_updated 更新 -> 只补 WSL 缺的 message/part; time_updated 只增不减 (max 更新)。 - 传输: export gzip -> `wsl -- bash -c "cat > file"` stdin 管道(绕 UNC OSError 22); WSL 侧脚本一律经 stdin 喂 python3 -, 脚本只读本地文件(避免 stdin 冲突)。 - 跨版本 schema: pragma table_info 取目标表列交集, INSERT OR IGNORE。 - 预算 150s; 异常吞掉只写日志([sync] 前缀), 绝不抛出阻塞退出链路。 """ import gzip import io import json import os import sqlite3 import subprocess import sys import time try: sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") except Exception: pass TAG = "[sync]" HOME = os.path.expanduser("~") WIN_DB = os.path.join(HOME, ".local", "share", "opencode", "opencode.db") STATE = os.path.join(HOME, ".config", "opencode", ".oc_sync_state.json") LOG = os.path.join(HOME, ".config", "opencode", "oc-session-all.log") REMOTE_IMPORT = "/root/.oc_sync_import.json.gz" REMOTE_PROBE = "/root/.oc_sync_probe.json" REMOTE_RESULT = "/root/.oc_sync_result.json" DISTRO = "Ubuntu-24.04" BUDGET = 150 # 同步的表(WSL 侧缺表/缺列自动跳过) TABLES = ["project", "session", "session_v2", "session_message", "part"] # ★ project.worktree 路径转换(Win 绝对路径 -> WSL 绝对路径) # Win 侧 session 的 project.worktree = C:/Users/ypshidifu/... (OC 启动 cwd) # 推给 WSL 后原样是 Win 路径 → WSL 侧 session 面板提示 # 「Session location unavailable / C:/Users/ypshidifu / Choose another directory」。 # 必须把 C:/Users/ypshidifu 前缀转成 WSL 的 /root。 _WIN_HOME_PREFIX_SL = "C:/Users/ypshidifu" _WSL_HOME_PREFIX = "/root" def log(m): try: with open(LOG, "a", encoding="utf-8") as f: f.write("[%s] %s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%S"), TAG, m)) except Exception: pass def wsl_py(src, timeout=120): """经 stdin 喂脚本给 wsl python3 - (脚本内禁止再读 stdin)。返回 (rc, out_bytes)。""" r = subprocess.run( ["wsl.exe", "-d", DISTRO, "--", "python3", "-"], input=src.encode("utf-8"), capture_output=True, timeout=timeout, ) return r.returncode, r.stdout def wsl_push(data, remote_path, timeout=120): cmd = 'cat > "%s"' % remote_path r = subprocess.run( ["wsl.exe", "-d", DISTRO, "--", "bash", "-c", cmd], input=data, capture_output=True, timeout=timeout, ) return r.returncode def wsl_cat(remote_path, timeout=60): r = subprocess.run( ["wsl.exe", "-d", DISTRO, "--", "cat", remote_path], capture_output=True, timeout=timeout, ) raw = r.stdout or b"" if raw[:1] == b"{": return raw.decode("utf-8", errors="replace") try: t = raw.decode("utf-16-le") if "{" in t: return t[t.index("{"):] except Exception: pass return raw.decode("utf-8", errors="replace") PROBE_SRC = r''' import json, os, sqlite3, glob out = {"ok": False} try: cands = ["/root/.local/share/opencode/opencode.db"] cands += glob.glob("/root/.local/share/**/opencode.db", recursive=True) db = next((c for c in cands if os.path.exists(c)), None) if not db: out["err"] = "wsl db not found" else: con = sqlite3.connect(db) def tu(t): try: return {r[0]: (r[1] or 0) for r in con.execute("select id, time_updated from " + t)} except Exception: return {} out["db"] = db # 2026-10-20:双表驱动(新版 OC 活跃会话写 session_v2,旧版写 session) out["sessions"] = tu("session") out["sessions_v2"] = tu("session_v2") out["msgs"] = tu("session_message") out["ok"] = True con.close() except Exception as e: out["err"] = str(e) open("/root/.oc_sync_probe.json", "w", encoding="utf-8").write(json.dumps(out)) ''' IMPORT_SRC = r''' import gzip, json, os, sqlite3 res = {"ok": False} try: raw = gzip.decompress(open("/root/.oc_sync_import.json.gz", "rb").read()) payload = json.loads(raw.decode("utf-8")) con = sqlite3.connect("/root/.local/share/opencode/opencode.db") cur = con.cursor() stats = {} for t, rows in payload.get("tables", {}).items(): try: tgt_cols = [r[1] for r in cur.execute("pragma table_info(%s)" % t)] except Exception: tgt_cols = [] if not tgt_cols: stats[t] = "missing-table" continue ins = 0 upd = 0 for row in rows: cols = [c for c in tgt_cols if c in row] if not cols or "id" not in cols: continue try: cur.execute( "INSERT OR IGNORE INTO %s (%s) VALUES (%s)" % (t, ",".join(cols), ",".join("?" * len(cols))), [row[c] for c in cols], ) if cur.rowcount > 0: ins += cur.rowcount elif "time_updated" in tgt_cols and "time_updated" in row and row["time_updated"]: cur.execute( "UPDATE %s SET time_updated=? WHERE id=? AND (time_updated IS NULL OR time_updated /root" % fw["fixed"]) if fw.get("err"): log("worktree fix err: " + str(fw["err"])) except Exception as e: log("worktree fix exception: " + str(e)) # WSL 侧合并双表(id -> 最新 time_updated) wsl_all = dict(wsl_sessions) for k, v in (wsl_sessions_v2 or {}).items(): wsl_all[k] = max(wsl_all.get(k, 0), v) # 读 Win session_v2(新版主表;缺表当空) try: win = sqlite3.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True) win_sessions_v2 = {r[0]: (r[1] or 0) for r in win.execute("select id, time_updated from session_v2")} except Exception: win_sessions_v2 = {} finally: win.close() # Win 侧合并双表 win_all = dict(win_sessions) for k, v in win_sessions_v2.items(): win_all[k] = max(win_all.get(k, 0), v) # 幂等快照(按合并后的双表快照判断,任一表变都触发重推) snapshot = { "session": win_sessions, "session_v2": win_sessions_v2, } try: state = json.load(open(STATE, encoding="utf-8")) except Exception: state = {} # 兼容旧版快照(旧版直接存 {id: time} 平铺) if not isinstance(state, dict) or "session" not in state: state = {"session": state if isinstance(state, dict) else {}, "session_v2": {}} if state == snapshot: log("unchanged, skip (session=%d v2=%d)" % (len(win_sessions), len(win_sessions_v2))) return # diff:合并双表后的缺失/过期 need_sessions = [s for s in win_all if s not in wsl_all] stale = [s for s in win_all if s in wsl_all and win_all[s] > wsl_all[s]] targets = set(need_sessions) | set(stale) if not targets: json.dump(snapshot, open(STATE, "w", encoding="utf-8")) log("no missing/stale sessions; state refreshed (session=%d v2=%d)" % (len(win_sessions), len(win_sessions_v2))) return win = sqlite3.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True) win.row_factory = sqlite3.Row try: # collect message ids per target session (need full set for new, missing-only handled at import by OR IGNORE) msg_rows = [] for r in win.execute( "select * from session_message where session_id in (%s)" % ",".join("?" * len(targets)), list(targets), ): msg_rows.append(dict(r)) msg_ids = [m["id"] for m in msg_rows if m["id"] not in wsl_msgs] missing_msg = [m for m in msg_rows if m["id"] not in wsl_msgs] tables = {} # project rows referenced by target sessions(双表并集查 project_id) pids = set() q1 = "select distinct project_id from session where id in (%s)" % ",".join("?" * len(targets)) for r in win.execute(q1, list(targets)): if r[0]: pids.add(r[0]) try: for r in win.execute( "select distinct project_id from session_v2 where id in (%s)" % ",".join("?" * len(targets)), list(targets), ): if r[0]: pids.add(r[0]) except Exception: pass if pids: tables["project"] = [ dict(r) for r in win.execute( "select * from project where id in (%s)" % ",".join("?" * len(pids)), list(pids), ) ] tables["session"] = [ dict(r) for r in win.execute( "select * from session where id in (%s)" % ",".join("?" * len(targets)), list(targets), ) ] # ★ Win 路径转换(C:/Users/ypshidifu / C:\Users\ypshidifu -> /root) # 修 WSL 侧「Session location unavailable / C:/Users/ypshidifu」 # 2026-10-20 二次实锤:OC 2.x 的 location 显示读 session/session_v2 自己的 # directory 列,project.worktree 也要转,三处一起。 def _conv_win_path(p): if not isinstance(p, str): return p if p.startswith(_WIN_HOME_PREFIX_SL): return _WSL_HOME_PREFIX + p[len(_WIN_HOME_PREFIX_SL):] if p.startswith("C:" + chr(92) + "Users" + chr(92) + "ypshidifu"): return _WSL_HOME_PREFIX + p[len("C:" + chr(92) + "Users" + chr(92) + "ypshidifu"):] return p if tables.get("project"): for _pr in tables["project"]: _pr["worktree"] = _conv_win_path(_pr.get("worktree")) for _t in ("session", "session_v2"): for _r in tables.get(_t) or []: if "directory" in _r: _r["directory"] = _conv_win_path(_r.get("directory")) try: tables["session_v2"] = [ dict(r) for r in win.execute( "select * from session_v2 where id in (%s)" % ",".join("?" * len(targets)), list(targets), ) ] except Exception: tables["session_v2"] = [] tables["session_message"] = missing_msg # parts of missing messages if msg_ids: parts = [] chunk = 400 for i in range(0, len(msg_ids), chunk): sub = msg_ids[i : i + chunk] for r in win.execute( "select * from part where message_id in (%s)" % ",".join("?" * len(sub)), sub, ): parts.append(dict(r)) tables["part"] = parts else: tables["part"] = [] except Exception as e: win.close() log("win export ERR: " + str(e)) return win.close() payload = json.dumps({"tables": tables}, ensure_ascii=False).encode("utf-8") blob = gzip.compress(payload, 1) nmsg = len(tables.get("session_message") or []) npart = len(tables.get("part") or []) log( "diff: new=%d stale=%d msgs=%d parts=%d payload=%dKB" % (len(need_sessions), len(stale), nmsg, npart, len(blob) // 1024) ) try: rc = wsl_push(blob, REMOTE_IMPORT, timeout=120) if rc != 0: log("push rc=" + str(rc)) return rc, _ = wsl_py(IMPORT_SRC, timeout=120) if rc != 0: log("import rc=" + str(rc)) return res = json.loads(wsl_cat(REMOTE_RESULT)) log("import result: " + json.dumps(res, ensure_ascii=False)[:300]) json.dump(snapshot, open(STATE, "w", encoding="utf-8")) log("state updated (session=%d v2=%d) in %ds" % (len(win_sessions), len(win_sessions_v2), int(time.time() - t0))) except Exception as e: log("sync exception: " + str(e)) if __name__ == "__main__": try: main() except Exception as e: log("FATAL: " + str(e)) sys.exit(0)