Files
oc_session/tools/oc-session-sync.py
T
ypshidifu a41cf92379 session-sync 洗白扩展到 session/session_v2.directory:OC 2.x location 显示读会话表自身 directory 列
- Win DB 实锤:目标会话 ses_eb074c0daffe 的 session_v2.directory = C:/Users/ypshidifu(截图报错来源)
- FIX_WORKTREE_SRC 扩展:project.worktree + session.directory + session_v2.directory 三表全洗(幂等,每次无条件跑)
- 推送侧转换统一 _conv_win_path,session/session_v2 行的 directory 列同步转换
2026-10-20 23:20:44 +08:00

446 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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<?)"
% t,
[row["time_updated"], row["id"], row["time_updated"]],
)
upd += cur.rowcount
except Exception:
pass
stats[t] = {"ins": ins, "upd": upd}
con.commit()
con.close()
res = {"ok": True, "stats": stats}
except Exception as e:
res = {"err": str(e)}
open("/root/.oc_sync_result.json", "w", encoding="utf-8").write(json.dumps(res))
'''
# ★ 存量 Win 路径洗白(2026-10-20 二次实锤:OC 2.x 的 "Session location" 读的是
# session/session_v2 表自身的 directory 列,不只是 project.worktree!
# 目标会话 ses_eb074c0daffe 的 session_v2.directory = "C:/Users/ypshidifu" 即截图来源。
# 且 IMPORT 用 INSERT OR IGNORE —— WSL 已存在的行永远不会被 UPDATE,
# 推送侧转换只对「新插入」的行生效。此脚本每次同步无条件跑(幂等 UPDATE),
# 把三张表里所有 Win home 前缀的路径洗成 /root。
FIX_WORKTREE_SRC = r'''
import sqlite3
BK = chr(92)
PRE_SL = "C:/Users/ypshidifu"
PRE_BK = "C:" + BK + "Users" + BK + "ypshidifu"
res = {"fixed": {}, "err": None}
def conv(p):
if not p or not isinstance(p, str):
return None
if p.startswith(PRE_SL):
return "/root" + p[len(PRE_SL):]
if p.startswith(PRE_BK):
return "/root" + p[len(PRE_BK):]
return None
try:
con = sqlite3.connect("/root/.local/share/opencode/opencode.db")
cur = con.cursor()
# project.worktree
n = 0
for pid, wt in cur.execute("select id, worktree from project").fetchall():
nw = conv(wt)
if nw:
cur.execute("update project set worktree=? where id=?", (nw, pid))
n += 1
res["fixed"]["project.worktree"] = n
# session.directory(列存在才跑)
def fix_dir(table):
cols = [r[1] for r in cur.execute("PRAGMA table_info(%s)" % table)]
if "directory" not in cols:
return -1
n = 0
for sid, d in cur.execute("select id, directory from %s" % table).fetchall():
nw = conv(d)
if nw:
cur.execute("update %s set directory=? where id=?" % table, (nw, sid))
n += 1
return n
res["fixed"]["session.directory"] = fix_dir("session")
res["fixed"]["session_v2.directory"] = fix_dir("session_v2")
con.commit()
con.close()
except Exception as e:
res["err"] = str(e)
open("/root/.oc_fix_wt_result.json", "w", encoding="utf-8").write(json.dumps(res))
'''
def main():
t0 = time.time()
if not os.path.exists(WIN_DB):
log("win db missing: " + WIN_DB)
return
# 2026-10-20:双表驱动。新版 OC(2.0.27) 活跃会话写 session_v2,旧表 session 是历史遗留。
# 只盯 session 旧表会导致「Win 新建会话全在 v2 → 旧表无变化 → 永远 skip → WSL 读不到」。
win = sqlite3.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True)
try:
win_sessions = {r[0]: (r[1] or 0) for r in win.execute("select id, time_updated from session")}
except Exception as e:
log("win session read ERR: " + str(e))
return
finally:
win.close()
try:
rc, _ = wsl_py(PROBE_SRC, timeout=90)
if rc != 0:
log("wsl probe rc=" + str(rc) + " (vm cold-boot or python missing?)")
return
probe = json.loads(wsl_cat(REMOTE_PROBE))
if not probe.get("ok"):
log("wsl probe ERR: " + str(probe.get("err")))
return
except Exception as e:
log("wsl probe exception: " + str(e))
return
wsl_sessions = probe.get("sessions") or {}
wsl_sessions_v2 = probe.get("sessions_v2") or {}
wsl_msgs = probe.get("msgs") or {}
# ★ 存量 worktree 洗白:每次无条件跑(幂等,修 INSERT OR IGNORE 洗不掉存量行的洞)
try:
rc_fw, _ = wsl_py(FIX_WORKTREE_SRC, timeout=60)
if rc_fw == 0:
fw = json.loads(wsl_cat("/root/.oc_fix_wt_result.json"))
if fw.get("fixed"):
log("worktree fixed: %d rows -> /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)