model-sync: 加 opencode.json.2 provider 同步;session-sync: 双表驱动

This commit is contained in:
ypshidifu
2026-10-20 20:49:29 +08:00
parent 5cd94dc4e1
commit afdafb0ed1
2 changed files with 379 additions and 12 deletions
+25 -12
View File
@@ -66,18 +66,15 @@ PLUGINS = [
"/root/.config/opencode/plugins/model-manager.ts"),
(os.path.join(HOME, ".config", "opencode", "mcpcfg.txt"),
"/root/.config/opencode/mcpcfg.txt"),
# ── 2026-10-20 补:插件「状态文件」也要推(修「格式化 xiaomi 后 WSL 仍可见」)──
# WSL 侧的 model-manager 读的是 WSL 自己的 cfg/removed/disabled 清单,若不同步,
# Win 上「停用/删除」的厂商在 WSL 无从感知 → WSL 仍按旧 catalog 显示 xiaomi/Agnes。
# pristine 不推:6.5MB 巨大,且 WSL 侧若缺时会自动按当前 catalog 重新拍一份,无需强同步。
# ── 2026-10-20 治本:provider 配置同步(替代 model-manager 压制清单)──
# cc-switch 把活跃 provider(如 agnes)注入到 opencode.json.2;
# 把整个 opencode.json.2 推给 WSL,让 WSL OC 读到一致的 provider 状态。
# 同步后 WSL 侧的 model-manager 不再需要 removed/disabled 清单压制 agnes,
# 因为 cc-switch 删 agnes → Win opencode.json.2 更新 → WSL 同步 → agnes 消失。
(os.path.join(HOME, ".config", "opencode", "opencode.json.2"),
"/root/.config/opencode/opencode.json.2"),
(os.path.join(HOME, ".config", "opencode", "model-manager.cfg.json"),
"/root/.config/opencode/model-manager.cfg.json"),
(os.path.join(HOME, ".config", "opencode", "model-manager.removed.json"),
"/root/.config/opencode/model-manager.removed.json"),
(os.path.join(HOME, ".config", "opencode", "model-manager.disabled.json"),
"/root/.config/opencode/model-manager.disabled.json"),
(os.path.join(HOME, ".config", "opencode", "model-manager.stoplist.bak.json"),
"/root/.config/opencode/model-manager.stoplist.bak.json"),
]
@@ -109,7 +106,7 @@ def fingerprint():
def read_win_data():
"""只读 Win db: 返回 (catalog_value, credentials_json, n_creds)。"""
"""只读 Win db + opencode.json.2:返回 (catalog_value, credentials_json, n_creds)。"""
import sqlite3 as _s
con = _s.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True)
try:
@@ -125,7 +122,13 @@ def read_win_data():
def build_plugin_blob():
"""Win 生效份插件 -> {dst_path: base64}。"""
"""Win 生效份插件 + opencode.json.2 -> {dst_path: base64}。
额外:把 WSL 侧 model-manager 压制清单(removed/disabled/stoplist.bak)重置为空,
因为 opencode.json.2 里 provider 已同步到 WSL,不再需要靠压制清单删厂商。
若 Win 侧清单非空(比如用户还在用 model-manager 手动压制某厂商),
则把 Win 的清单同步过去,保持两边压制行为一致。
"""
blob = {}
for src, dst in PLUGINS:
if not os.path.exists(src):
@@ -134,6 +137,16 @@ def build_plugin_blob():
blob[dst] = base64.b64encode(open(src, "rb").read()).decode("ascii")
except Exception:
pass
# 把 model-manager 压制清单也同步(Win 非空就推,空就推空,保证 WSL 与 Win 对齐)
for fname in ["model-manager.removed.json", "model-manager.disabled.json",
"model-manager.stoplist.bak.json"]:
src = os.path.join(HOME, ".config", "opencode", fname)
dst = "/root/.config/opencode/" + fname
if os.path.exists(src):
try:
blob[dst] = base64.b64encode(open(src, "rb").read()).decode("ascii")
except Exception:
pass
return blob
+354
View File
@@ -0,0 +1,354 @@
# -*- 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"]
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))
'''
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 {}
# 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),
)
]
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)