Files
lsdefine--genericagent/frontends/workspace_cmd.py
T
2026-07-13 13:04:19 +08:00

479 lines
18 KiB
Python

"""Workspace 命令的共享逻辑(tuiapp_v2 / tui_v3 复用)。
设计要点(详见对话设计稿):
* **兼容旧入口** `plugins/project_mode.py` 与 `memory/project_mode_sop.md` 的 pid 锚。
前端在
`<repo>/temp/projects/<name>` 建一个指向用户真实绝对路径的目录联接(junction),
并可按需写激活锚 `<repo>/temp/.active_project.<pid>`。project_mode 插件
照常每轮注入 L1,并把 project_memory.md / 产物经 junction 写进真实仓库根
(与 Claude Code 在仓库根放 CLAUDE.md 同理,已接受)。
* **路径基准必须与插件一致**:插件的 `_TEMP` 是基于其 `__file__` 的 `<repo>/temp`
绝对路径(非 cwd)。本模块也从自身 `__file__` 推 `<repo>/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: <name>]`(见 _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)