Files
workbuddy_skills/dsh-workflow/references/dsh-auto-handoff-chain/audit-mirror.py
T
admin e03465c398 按用户令提交:把此前未纳管的 9 个技能目录一并入库
用户令逐字:「E:/ProgramData/.workbuddy/skills  提交仓库是指的这里」——
即本目录就是仓库(2026-10-07 已在本目录建仓,见当日日志 §22),本轮把余下未纳管的 9 个技能一并提交。

本次入库(9 个技能,46 个文件):
1、`AI HOT`
2、`draw-ui`
3、`dsh-diagnose`
4、`dsh-knowledge`
5、`dsh-local-env`
6、`dsh-opensource-release`
7、`dsh-workflow`
8、`oil-motion`
9、`skills-security-check`

提交前核对:
· **凭据类扫描**(`*.env` / `*token*` / `*.key` / `*secret*` / `*.pem`)⇒ **零命中** ✓;
· 体积合计约 20 MB(`draw-ui` 12M + `oil-motion` 6.5M 是大头,形态为配图/素材 ——
  仓库 `.gitignore` 里明写「`assets/*.png` 是内容不是产物」⇒ 属刻意入库);
· 运行产物仍按既定规则排除(`__pycache__` / `logs/` / `tmp/` / `.venv/` / `*.egg-info` / `uv.lock` / `.workbuddy/`)。
2026-10-08 22:29:08 +08:00

364 lines
15 KiB
Python

# -*- coding: utf-8 -*-
"""同名重复会话分组 · 全库只读体检
================================================
用途:一条命令扫出「同一工作区在磁盘上有几份」,以及由此派生的四类隐患:
① 会话分组重复(projects\\<cwd-slug> 与 DB 的 sessions.cwd 不再一一对应)
② 锁命名空间分裂(dsh 锁按 --ws 算键 ⇒ 两处各一把锁、互不排斥)
③ 入口文件双份 / 已分叉(接续棒会读到过期的「本轮动作」)
④ automation 的 cwds 指向非权威侧
只读。绝不写 workbuddy.db、不动任何工作区文件。
🔴 2026-09-28 现状:本脚本默认比对的「镜像侧」`E:\ProgramDSH\AIProject` **已整树删除**
⇒ 脚本**功能已失效**(两侧恒为空对 ⇒ 只走"跳过"分支)。代码保留备查;若日后重建镜像树,可原样复用。
判据与成因分类见 SKILL.md §0.6(甲~己)与「实测事故·四」。
用法:
python audit-mirror.py # 全量(默认)
python audit-mirror.py --roots E:\\ProgramData\\AIProject E:\\ProgramDSH\\AIProject
python audit-mirror.py --only ai1net_ui # 只看名字含该串的工作区
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import os
import re
import sqlite3
import sys
BS = chr(92) # 反斜杠;用 chr() 免得被各种 shell 的转义吃掉
DSH_HOME_DEFAULT = r"E:\ProgramDSH\.dsh"
WB_HOME_DEFAULT = r"E:\ProgramData\.workbuddy"
DEFAULT_ROOTS = [
r"E:\ProgramData\AIProject",
r"E:\ProgramDSH\AIProject",
r"E:\ProgramData\WorkBuddy",
r"C:\Users\Administrator\WorkBuddy",
]
ENTRY_KEYS = ("接续入口", "交接单", "接续包")
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "_venv", "dist", "build", ".next"}
# ---------------------------------------------------------------- 工具
def norm(p: str) -> str:
return p.replace("/", BS).rstrip(BS)
def slug_of(path: str) -> str:
"""复现宿主的 cwd -> 分组目录名 规则:分隔符统一成 '-'、合并连续、去首尾、盘符转小写。
实测(2026-09-27 · 7/7 命中):
'E:/ProgramData/AIProject/ai1net_ui' -> 'e-ProgramData-AIProject-ai1net_ui'
注意:**只小写盘符**,中间路径段保留原大小写。
"""
s = path.strip()
for ch in (BS, ":", "/", " "):
s = s.replace(ch, "-")
s = re.sub("-+", "-", s).strip("-")
return (s[0].lower() + s[1:]) if len(s) > 1 and s[1] == "-" else s
def ts(v) -> str:
try:
return datetime.datetime.fromtimestamp(v / 1000).strftime("%m-%d %H:%M:%S")
except Exception:
return str(v)
def manifest(root: str, cap: int = 4000) -> dict:
out = {}
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for f in filenames:
full = os.path.join(dirpath, f)
try:
st = os.stat(full)
except OSError:
continue
out[os.path.relpath(full, root)] = (st.st_size, int(st.st_mtime))
if len(out) >= cap:
out["__TRUNCATED__"] = (1, 0)
return out
return out
def compare(a: str, b: str) -> dict:
ma, mb = manifest(a), manifest(b)
ka, kb = set(ma) - {"__TRUNCATED__"}, set(mb) - {"__TRUNCATED__"}
only_a, only_b = sorted(ka - kb), sorted(kb - ka)
size_diff = sorted(k for k in ka & kb if ma[k][0] != mb[k][0])
return {
"files_a": len(ka), "files_b": len(kb),
"only_a": only_a[:6], "n_only_a": len(only_a),
"only_b": only_b[:6], "n_only_b": len(only_b),
"size_diff": size_diff[:6], "n_size_diff": len(size_diff),
"trunc": ("__TRUNCATED__" in ma or "__TRUNCATED__" in mb),
}
def md5(p: str) -> str:
with open(p, "rb") as fh:
return hashlib.md5(fh.read()).hexdigest()[:8]
def hr(title: str) -> None:
print()
print("=" * 78)
print(title)
print("=" * 78)
# ---------------------------------------------------------------- 各节
def read_db(wb_home: str):
db = os.path.join(wb_home, "workbuddy.db")
if not os.path.isfile(db):
print("⛔ 找不到 %s" % db)
sys.exit(2)
con = sqlite3.connect("file:%s?mode=ro" % db.replace(BS, "/"), uri=True)
sessions = list(con.execute(
"select id,cwd,is_background_automation,deleted_at,created_at,last_activity_at,"
"coalesce(custom_title,title) from sessions order by cwd,created_at"))
automations = list(con.execute(
"select id,name,status,schedule_type,scheduled_at,cwds,created_at from automations order by created_at"))
return con, sessions, automations
def sec1_sessions(sessions, roots, only):
hr("§1 会话 cwd 存在性 + 同名镜像检测")
bycw = {}
for r in sessions:
bycw.setdefault(norm(r[1]) if r[1] else "", []).append(r)
pairs = []
for c in sorted(bycw):
if not c:
continue
base = os.path.basename(c)
if only and only not in base:
continue
sibs = [os.path.join(rt, base) for rt in roots
if os.path.isdir(os.path.join(rt, base))
and os.path.normcase(os.path.join(rt, base)) != os.path.normcase(c)]
print("\n--- cwd = %s" % c)
print(" 目录存在: %-5s 会话数: %d" % (os.path.isdir(c), len(bycw[c])))
for r in bycw[c]:
print(" %s auto=%s del=%s created=%s last=%s %r" % (
r[0][:8], r[2], r[3], ts(r[4]), ts(r[5]), r[6][:36]))
if sibs:
for s in sibs:
pairs.append((c, s))
print(" [戊] 同名镜像: %s" % s)
else:
print(" [ok] 无同名镜像")
return pairs
def sec2_pairs(pairs):
hr("§2 镜像对内容比对(真副本 / 空骨架 / 谁更全)")
if not pairs:
print("(无镜像对)")
for a, b in pairs:
print("\n--- %s\n vs %s" % (a, b))
if not (os.path.isdir(a) and os.path.isdir(b)):
print(" ⚠️ 有一侧不存在 —— 属**己类**(入口声明与磁盘不符),不是戊类")
continue
r = compare(a, b)
print(" 文件数 A=%d B=%d%s" % (r["files_a"], r["files_b"],
" (已截断)" if r["trunc"] else ""))
print(" 仅 A 有 %d;仅 B 有 %d;同名但大小不同 %d" % (
r["n_only_a"], r["n_only_b"], r["n_size_diff"]))
for k, lbl in (("only_a", "A 独有"), ("only_b", "B 独有"), ("size_diff", "大小不同")):
if r[k]:
print(" %s: %s" % (lbl, r[k]))
if not r["trunc"] and r["n_only_a"] == r["n_only_b"] == r["n_size_diff"] == 0:
print(" => 逐字节同构(真副本,最危险)")
elif r["n_only_a"] > r["n_only_b"]:
print(" => A 更全 ⇒ **A 极可能是权威侧**")
elif r["n_only_b"] > r["n_only_a"]:
print(" => B 更全 ⇒ **B 极可能是权威侧**")
def sec3_entries(sessions, roots, only):
hr("§3 入口 / 交接类文件:双份与分叉")
seen_base = set()
for r in sessions:
if not r[1]:
continue
c = norm(r[1])
base = os.path.basename(c)
if base in seen_base or (only and only not in base):
continue
seen_base.add(base)
dirs, seen = [c], {os.path.normcase(c)}
for rt in roots:
p = os.path.join(rt, base)
if os.path.isdir(p) and os.path.normcase(p) not in seen:
dirs.append(p)
seen.add(os.path.normcase(p))
print("\n--- 工作区 %s" % base)
found = []
for d in dirs:
if not os.path.isdir(d):
continue
for f in sorted(os.listdir(d)):
if f.lower().endswith(".md") and any(k in f for k in ENTRY_KEYS):
p = os.path.join(d, f)
found.append((p, os.path.getsize(p),
datetime.datetime.fromtimestamp(os.path.getmtime(p)).strftime("%m-%d %H:%M"),
md5(p)))
if not found:
print(" (无入口类文件)")
continue
for p, sz, mt, h in found:
print(" %-62s %7dB %s md5=%s" % (p, sz, mt, h))
names = {}
for p, sz, mt, h in found:
names.setdefault(os.path.basename(p), []).append((p, sz, h))
for nm, lst in names.items():
if len(lst) > 1 and len({x[2] for x in lst}) > 1:
print(" 🔴 已分叉: %s 字节 %s" % (nm, [x[1] for x in lst]))
def sec4_automations(automations, sessions, roots):
hr("§4 automation 的 cwds 归属")
import json as _j
live = {norm(r[1]) for r in sessions if r[1]}
for aid, name, status, stype, at, cwds, created in automations:
try:
arr = _j.loads(cwds) if cwds else []
except Exception:
arr = [cwds]
print("\n--- %s %r" % (aid[:8], (name or "")[:44]))
print(" status=%s type=%s at=%s created=%s" % (status, stype, at, ts(created)))
for c in arr:
c2 = norm(c)
others = [os.path.join(rt, os.path.basename(c2)) for rt in roots
if os.path.isdir(os.path.join(rt, os.path.basename(c2)))
and os.path.normcase(os.path.join(rt, os.path.basename(c2))) != os.path.normcase(c2)]
ok_exist = os.path.isdir(c2)
authoritative = c2 in live or any(norm(o) in live for o in others)
marks = []
marks.append("存在" if ok_exist else "🔴不存在(己类:会建幽灵分组)")
if others:
marks.append("有同名镜像%d个" % len(others))
if not authoritative:
marks.append("🔴会话不在该路径(疑似写到非权威侧)")
print(" cwds=%s %s" % (c2, " · ".join(marks)))
for o in others:
print(" 镜像侧: %s" % o)
def sec5_locks(dsh_home):
hr("§5 dsh 锁命名空间残留(按改动时间倒序)")
lk = os.path.join(dsh_home, ".locks")
if not os.path.isdir(lk):
print(" (无 %s)" % lk)
return
rows = []
for d in sorted(os.listdir(lk)):
p = os.path.join(lk, d)
if not os.path.isdir(p):
continue
files = []
for rt, _, fs in os.walk(p):
files += [os.path.relpath(os.path.join(rt, f), p) for f in fs]
rows.append((os.path.getmtime(p), d, len(files)))
for mt, d, n in sorted(rows, reverse=True):
print(" %s %-52s 文件=%d" % (
datetime.datetime.fromtimestamp(mt).strftime("%Y-%m-%d %H:%M:%S"), d, n))
# 同一工作区两套键
print("\n == 同一工作区出现两套键(锁不互斥的判据) ==")
fams = {}
for _, d, _n in rows:
m = re.match(r"^(e|d|c|f)-(programdata|programdsh|github|tmp)?-?(.*?)-[0-9a-f]{8}$", d)
if m:
fams.setdefault(m.group(3), set()).add(d)
hit = {k: v for k, v in fams.items() if len(v) > 1}
if not hit:
print(" 无")
for k, v in sorted(hit.items()):
print(" 🔴 %s -> %s" % (k, sorted(v)))
def sec6_groups(wb_home, sessions):
hr("§6 projects 分组目录 <-> DB cwd 是否一一对应")
proj = os.path.join(wb_home, "projects")
if not os.path.isdir(proj):
print(" (无 %s)" % proj)
return
cwds = sorted({norm(r[1]) for r in sessions if r[1]})
groups = sorted(d for d in os.listdir(proj) if os.path.isdir(os.path.join(proj, d)))
idx = {slug_of(c).lower(): c for c in cwds}
print(" 分组数=%d 不同 cwd 数=%d" % (len(groups), len(cwds)))
orphan = []
for d in groups:
n = len([f for f in os.listdir(os.path.join(proj, d)) if f.endswith(".jsonl")])
hit = idx.get(d.lower())
print(" %-52s jsonl=%-3d %s" % (d, n, ("<= " + hit) if hit else "🔴 无对应 cwd(孤儿分组)"))
if not hit:
orphan.append(d)
miss = [c for c in cwds if slug_of(c).lower() not in [g.lower() for g in groups]]
print("\n 孤儿分组 = %d %s" % (len(orphan), orphan))
print(" 未建组的 cwd = %d %s" % (len(miss), miss))
if not orphan and not miss:
print(" ✅ 一一对应,无重复分组")
def sec7_mirror_tree(roots):
hr("§7 根目录整树比对(找「镜像树」,甲/戊/己的共同温床)")
ai = [os.path.join(r, "AIProject") for r in roots]
a, b = r"E:\ProgramData\AIProject", r"E:\ProgramDSH\AIProject"
if not (os.path.isdir(a) and os.path.isdir(b)):
print(" (无 ProgramData\\AIProject / ProgramDSH\\AIProject 对,跳过)")
return
la, lb = set(os.listdir(a)), set(os.listdir(b))
both = sorted(la & lb)
print(" A = %s 目录数=%d" % (a, len(la)))
print(" B = %s 目录数=%d" % (b, len(lb)))
print(" 同名(B 是 A 的镜像候选)= %d 个: %s" % (len(both), both))
print(" 仅 A 有 = %d 个: %s" % (len(la - lb), sorted(la - lb)))
print(" 仅 B 有 = %d 个: %s" % (len(lb - la), sorted(lb - la)))
if both:
print(" ⇒ ⚠️ 上面这些同名目录每多一个,就多一份「工作区归属判错」的机会")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--wb-home", default=WB_HOME_DEFAULT)
ap.add_argument("--dsh-home", default=DSH_HOME_DEFAULT)
ap.add_argument("--roots", nargs="*", default=None)
ap.add_argument("--only", default=None, help="只看名字含该串的工作区")
ns = ap.parse_args()
roots = ns.roots if ns.roots else DEFAULT_ROOTS
import json as _j
con, sessions, automations = read_db(ns.wb_home)
print("workbuddy home = %s" % ns.wb_home)
print("dsh home = %s" % ns.dsh_home)
print("候选根 = %s" % roots)
print("会话 %d 条 / automation %d 条" % (len(sessions), len(automations)))
pairs = sec1_sessions(sessions, roots, ns.only)
sec2_pairs(pairs)
sec3_entries(sessions, roots, ns.only)
sec4_automations(automations, sessions, roots)
sec5_locks(ns.dsh_home)
sec6_groups(ns.wb_home, sessions)
sec7_mirror_tree(roots)
hr("总判读口径")
print(" ① 同一工作区在磁盘上有几个目录?>1 ⇒ 戊类(先定权威再开工)")
print(" ② 入口声明的路径 Test-Path 是否成立?不成立 ⇒ 己类(回填入口头部,⛔ 别新建目录)")
print(" ③ automation 的 cwds 是否落在「会话实际所在的那一侧」?否 ⇒ 立刻改,否则多一个分组")
print(" ④ 旧侧入口是否已改 stub?只写「停维护」不算 ⇒ 两侧必分叉")
print(" ⑤ 锁有几套键?一个工作区两套 = 锁不互斥(比 UI 观感危险)")
if __name__ == "__main__":
main()