"""Workspace 命令的共享逻辑(tuiapp_v2 / tui_v3 复用)。 设计要点(详见对话设计稿): * **兼容旧入口** `plugins/project_mode.py` 与 `memory/project_mode_sop.md` 的 pid 锚。 前端在 `/temp/projects/` 建一个指向用户真实绝对路径的目录联接(junction), 并可按需写激活锚 `/temp/.active_project.`。project_mode 插件 照常每轮注入 L1,并把 project_memory.md / 产物经 junction 写进真实仓库根 (与 Claude Code 在仓库根放 CLAUDE.md 同理,已接受)。 * **路径基准必须与插件一致**:插件的 `_TEMP` 是基于其 `__file__` 的 `/temp` 绝对路径(非 cwd)。本模块也从自身 `__file__` 推 `/temp`(frontends/ 的上一级 即 repo 根),两边独立计算但结果一致,互不 import。 * **pid 语义**:插件读 `os.getpid()`(GA 进程)。前端就跑在 GA 进程里,写锚同样用 `os.getpid()`(不是 SOP 里 code_run 子进程用的 getppid)。 * **命名** `name = f"{basename}-{hash8}"`,hash8 = blake2b(规范化绝对路径)[:8]。 同一 workspace 恒定同名(幂等复用);hash 后缀又让 junction 名不与其它 UI 人工命名的 普通项目目录相撞。 * **junction 安全**:检测用 reparse 属性(`os.path.islink` 对 junction 返回 False!); 删除用 `os.rmdir`,**绝不 rmtree**(会击穿删真实文件)。cleanup 只动确认是 junction 且悬空/未注册的条目,真实目录(其它 UI 的普通项目)一律不碰。 """ from __future__ import annotations import hashlib import json import os import re import stat import subprocess import sys import time from typing import Optional # --------------------------------------------------------------------------- # # 路径基准(与 plugins/project_mode.py 的 _TEMP 保持一致) # --------------------------------------------------------------------------- # _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def _temp_root() -> str: return os.path.join(_REPO_ROOT, "temp") def _projects_root() -> str: return os.path.join(_temp_root(), "projects") def _anchor_path() -> str: """激活锚,pid 键控,与插件 `_ANCHOR` 同。""" return os.path.join(_temp_root(), f".active_project.{os.getpid()}") def _registry_path() -> str: return os.path.join(_temp_root(), "workspaces.json") _REGISTRY_VERSION = 1 # --------------------------------------------------------------------------- # # 命名 # --------------------------------------------------------------------------- # def _norm_abspath(p: str) -> str: """规范化绝对路径用于 hash:abspath + normcase(Windows 大小写不敏感 -> 同一目录恒定同名)。不走 realpath,避免解析 junction/symlink 带来的意外。""" return os.path.normcase(os.path.abspath(p)) def _ws_name(abs_path: str) -> str: base = os.path.basename(abs_path.rstrip("/\\")) or "ws" digest = hashlib.blake2b(_norm_abspath(abs_path).encode("utf-8")).hexdigest()[:8] return f"{base}-{digest}" def _link_path(name: str) -> str: return os.path.join(_projects_root(), name) # --------------------------------------------------------------------------- # # junction / symlink 跨平台封装(reparse 安全) # --------------------------------------------------------------------------- # def make_dir_link(target_abs: str, link_path: str) -> bool: """建目录联接。Windows 用 `mklink /J`(免管理员);POSIX 用 symlink。 成功返回 True;失败打印到 stderr 并返回 False。""" target_abs = os.path.abspath(target_abs) parent = os.path.dirname(link_path) try: os.makedirs(parent, exist_ok=True) except OSError as e: sys.stderr.write(f"[workspace] mkdir {parent} failed: {e}\n") return False if os.name == "nt": # mklink 是 cmd 内建,必须经 cmd 调用。列表传参由 subprocess 负责加引号, # 兼容含空格/中文的路径。 try: r = subprocess.run( ["cmd", "/c", "mklink", "/J", link_path, target_abs], capture_output=True, text=True, ) except OSError as e: sys.stderr.write(f"[workspace] mklink invoke failed: {e}\n") return False if r.returncode != 0 or not os.path.exists(link_path): sys.stderr.write(f"[workspace] mklink /J failed: " f"{(r.stderr or r.stdout or '').strip()}\n") return False return True # POSIX try: os.symlink(target_abs, link_path, target_is_directory=True) return True except OSError as e: sys.stderr.write(f"[workspace] symlink failed: {e}\n") return False def is_dir_link(path: str) -> bool: """是否目录联接/符号链接。**不能只用 os.path.islink**——它对 Windows junction 返回 False。改看 reparse point 属性 + reparse tag。""" try: if os.path.islink(path): # POSIX symlink、Windows 符号链接 return True except OSError: return False if os.name != "nt": return False try: st = os.lstat(path) except OSError: return False attrs = getattr(st, "st_file_attributes", 0) reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) if not (attrs & reparse): return False # 进一步认 tag:挂载点(junction)或符号链接 tag = getattr(st, "st_reparse_tag", 0) mount = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003) syml = getattr(stat, "IO_REPARSE_TAG_SYMLINK", 0xA000000C) if tag: return tag in (mount, syml) return True # 有 reparse 属性但拿不到 tag,保守视作链接(我们只在此目录建链) def link_target(path: str) -> Optional[str]: """读链接目标;清洗 Windows 的 \\??\\ / \\\\?\\ 前缀。失败返回 None。""" try: t = os.readlink(path) except OSError: return None for pre in ("\\??\\", "\\\\?\\"): if t.startswith(pre): t = t[len(pre):] break return t def remove_dir_link(path: str) -> bool: """只摘掉链接本身,绝不递归删目标。Windows junction / 符号链接目录用 os.rmdir, POSIX symlink 用 os.unlink。**调用前务必 is_dir_link 确认。**""" try: if os.name == "nt": os.rmdir(path) else: os.unlink(path) return True except OSError as e: sys.stderr.write(f"[workspace] remove link {path} failed: {e}\n") return False # --------------------------------------------------------------------------- # # 注册表 temp/workspaces.json(本功能私有;v2/v3 可能并发 -> 原子写) # --------------------------------------------------------------------------- # def registry_load() -> dict: try: with open(_registry_path(), encoding="utf-8") as fh: data = json.load(fh) if isinstance(data, dict) and data.get("version") == _REGISTRY_VERSION: items = data.get("items") if isinstance(items, dict): return items except (OSError, ValueError): pass return {} def _registry_save(items: dict) -> None: path = _registry_path() try: os.makedirs(os.path.dirname(path), exist_ok=True) tmp = f"{path}.{os.getpid()}.tmp" with open(tmp, "w", encoding="utf-8") as fh: json.dump({"version": _REGISTRY_VERSION, "items": items}, fh, ensure_ascii=False, separators=(",", ":")) os.replace(tmp, path) except OSError as e: sys.stderr.write(f"[workspace] registry save failed: {e}\n") # --------------------------------------------------------------------------- # # 会话→工作区映射 temp/session_workspaces.json — 让 /continue 即时恢复,不必先 # 聊一轮在日志留 PROJECT MODE 块。key=会话日志名, value=workspace 真实路径, # ""=已 off(区别于缺 key=无记录→回退扫日志)。手动操作触发、极低频,照搬注册 # 表原子写、不加锁。 # --------------------------------------------------------------------------- # def _session_map_path() -> str: return os.path.join(_temp_root(), "session_workspaces.json") def _session_map_load() -> dict: try: with open(_session_map_path(), encoding="utf-8") as fh: data = json.load(fh) if isinstance(data, dict) and data.get("version") == _REGISTRY_VERSION: items = data.get("items") if isinstance(items, dict): return items except (OSError, ValueError): pass return {} def _session_map_save(items: dict) -> None: path = _session_map_path() try: os.makedirs(os.path.dirname(path), exist_ok=True) tmp = f"{path}.{os.getpid()}.tmp" with open(tmp, "w", encoding="utf-8") as fh: json.dump({"version": _REGISTRY_VERSION, "items": items}, fh, ensure_ascii=False, separators=(",", ":")) os.replace(tmp, path) except OSError as e: sys.stderr.write(f"[workspace] session map save failed: {e}\n") def session_ws_set(log_path: str, target: str) -> None: """记录会话绑定的 workspace 路径;target="" 表示该会话已 off。""" key = os.path.basename(log_path or "") if not key: return items = _session_map_load() items[key] = target or "" _session_map_save(items) def session_ws_get(log_path: str): """路径=绑定 / ""=已 off / None=无记录(调用方回退扫日志)。""" key = os.path.basename(log_path or "") return _session_map_load().get(key) if key else None def session_map_prune() -> None: """删掉日志文件已不存在的孤儿条目(启动时调一次)。""" items = _session_map_load() logdir = os.path.join(_temp_root(), "model_responses") alive = {k: v for k, v in items.items() if os.path.isfile(os.path.join(logdir, k))} if len(alive) != len(items): _session_map_save(alive) def registry_upsert(name: str, abs_path: str) -> None: items = registry_load() items[name] = {"path": os.path.abspath(abs_path), "last_used": int(time.time())} _registry_save(items) def registry_remove(name: str) -> None: items = registry_load() if items.pop(name, None) is not None: _registry_save(items) def _mem_lines(link: str) -> int: """project_memory.md 行数(经 junction 读真实文件);读不到返回 0。""" mp = os.path.join(link, "project_memory.md") try: with open(mp, encoding="utf-8", errors="replace") as fh: return sum(1 for _ in fh) except OSError: return 0 def registry_list() -> list[dict]: """供 picker 候选列表:[{name, path, last_used, mem_lines, dangling}],按最近使用倒序。""" out = [] for name, ent in registry_load().items(): path = (ent or {}).get("path") or "" out.append({ "name": name, "path": path, "last_used": int((ent or {}).get("last_used") or 0), "mem_lines": _mem_lines(_link_path(name)) if path else 0, "dangling": not (path and os.path.isdir(path)), }) out.sort(key=lambda x: x["last_used"], reverse=True) return out # --------------------------------------------------------------------------- # # 校验 # --------------------------------------------------------------------------- # def validate_path(abs_path: str) -> tuple[bool, str]: if not abs_path or not abs_path.strip(): return False, "路径为空" p = abs_path.strip().strip('"').strip("'") if not os.path.isabs(p): return False, "需要绝对路径" if os.name == "nt" and p.startswith("\\\\"): return False, "不支持网络路径(UNC):junction 无法指向网络位置" if not os.path.exists(p): return False, f"路径不存在: {p}" if not os.path.isdir(p): return False, "不是目录" if _norm_abspath(p).startswith(_norm_abspath(_temp_root())): return False, "该路径已在 temp 内,无需 workspace" return True, "" # --------------------------------------------------------------------------- # # 主流程 # --------------------------------------------------------------------------- # def prepare(abs_path: str) -> dict: """准备 workspace,但不写进程级激活锚。返回: {ok, name, link, target, mem_text, warning, error}。 流程:校验 -> name -> 幂等建链 -> 确保 project_memory.md 存在 -> 注册 -> 回读记忆。 TUI 多会话隔离使用本函数,避免多个 session 争用同一个 pid 锚。""" p = abs_path.strip().strip('"').strip("'") if abs_path else "" ok, msg = validate_path(p) if not ok: return {"ok": False, "error": msg} target = os.path.abspath(p) name = _ws_name(target) link = _link_path(name) warning = "" # 幂等建链 if os.path.lexists(link): if is_dir_link(link): cur = link_target(link) if cur and _norm_abspath(cur) == _norm_abspath(target): pass # 已指向同一目标 -> 复用 else: remove_dir_link(link) if not make_dir_link(target, link): return {"ok": False, "error": "重建 junction 失败(见 stderr)"} else: # 极罕见:同名真实目录占位(其它 UI 的普通项目)。绝不覆盖。 return {"ok": False, "error": f"{link} 已是真实目录(可能是其它项目),拒绝覆盖"} else: if not make_dir_link(target, link): return {"ok": False, "error": "创建 junction 失败(见 stderr)"} # 确保 project_memory.md 存在(经 junction 落到真实仓库根) mem_path = os.path.join(link, "project_memory.md") if not os.path.isfile(mem_path): try: open(mem_path, "a", encoding="utf-8").close() except OSError as e: warning = f"无法创建 project_memory.md: {e}" mem_text = "" try: with open(mem_path, encoding="utf-8", errors="replace") as fh: mem_text = fh.read() except OSError: pass registry_upsert(name, target) return {"ok": True, "name": name, "link": link, "target": target, "mem_text": mem_text, "warning": warning, "error": ""} def activate(abs_path: str) -> dict: """设定并激活进程级 workspace。保留给旧 SOP / 非多会话 UI 使用。""" r = prepare(abs_path) if not r.get("ok"): return r try: with open(_anchor_path(), "w", encoding="utf-8") as fh: fh.write(r["name"]) except OSError as e: r = dict(r) r.update({"ok": False, "error": f"写激活锚失败: {e}"}) return r def deactivate() -> bool: """仅删激活锚;junction 与文件保留。返回是否原本处于激活态。""" anchor = _anchor_path() if os.path.isfile(anchor): try: os.remove(anchor) return True except OSError as e: sys.stderr.write(f"[workspace] deactivate failed: {e}\n") return False def current() -> Optional[dict]: """当前激活的 workspace:{name, path};未激活返回 None。""" anchor = _anchor_path() try: name = open(anchor, encoding="utf-8").read().strip() except OSError: return None if not name: return None ent = registry_load().get(name) or {} return {"name": name, "path": ent.get("path") or ""} def is_dangling(name: str) -> bool: """junction 指向的真实目标是否已失效(被删/盘断开)。""" link = _link_path(name) if not is_dir_link(link): return True t = link_target(link) return not (t and os.path.isdir(t)) def remove(name: str) -> None: """显式注销:删 junction(不动真实文件)+ 删注册表条目。若正激活该项目则一并删锚。""" link = _link_path(name) if is_dir_link(link): remove_dir_link(link) registry_remove(name) cur = current() if cur and cur["name"] == name: deactivate() # project_mode 插件注入的标记:`[PROJECT MODE: ]`(见 _build_injection)。 # 它随用户消息写进 model_responses 日志,故可据此判断被 /continue 的会话当时 # 在哪个 workspace。 _PM_RE = re.compile(r"\[PROJECT MODE:\s*([^\]\n]+?)\s*\]") def workspace_from_log(log_path: str) -> Optional[dict]: """扫一份 model_responses 日志,返回它最后激活的 workspace {name, path}; 仅当该 name 是**已注册的 workspace**(在 workspaces.json 里)才返回—— 普通 SOP 项目(无 hash、不在注册表)一律忽略。""" try: with open(log_path, encoding="utf-8", errors="replace") as f: content = f.read() except OSError: return None names = _PM_RE.findall(content) if not names: return None name = names[-1].strip() # 最后一次激活胜出 ent = registry_load().get(name) if not ent or not ent.get("path"): return None return {"name": name, "path": ent["path"]} def cleanup() -> None: """v2/v3 启动时调一次:清理 temp/projects/ 下**悬空或未注册**的 junction。 安全纪律:只处理 is_dir_link 确认的链接;真实目录(其它 UI 的普通项目)一律跳过。""" proot = _projects_root() if not os.path.isdir(proot): return registered = set(registry_load().keys()) try: entries = os.listdir(proot) except OSError: return for nm in entries: link = os.path.join(proot, nm) if not is_dir_link(link): continue # 真实目录 -> 不碰 t = link_target(link) dangling = not (t and os.path.isdir(t)) if dangling or nm not in registered: remove_dir_link(link)