aps-agent/server/state/branches.py

404 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.

# ============================================================
# 项目 -> 会话 -> 分支 三级树(moduleId: state-branches, 可重生 ✅)
# plan.md §4.x / 矩阵 51 行:BranchNode 生命周期(创建/命名/切换/丢弃/合并)
# 每会话一棵分支树(tree_root / active_node),父子关系持久化且可审计;
# 分支锚定 checkpoint pairId(世界状态点),切换分支即切换成对状态。
# ============================================================
from __future__ import annotations
import json
import os
import tempfile
import threading
import uuid
from typing import Any
from server.timeutil import fmt_dt
# 分支状态
_ACTIVE = "active"
_MERGED = "merged"
_DISCARDED = "discarded"
def _now() -> str:
from datetime import datetime
return fmt_dt(datetime.now())
def _uid() -> str:
return f"branch_{uuid.uuid4().hex[:10]}"
def _safe_scope(value: str) -> str:
cleaned = "".join(c for c in (value or "default") if c.isalnum() or c in "-_")
return cleaned[:64] or "default"
class BranchError(RuntimeError):
code = "BRANCH_ERROR"
class BranchNotFoundError(BranchError):
code = "BRANCH_NOT_FOUND"
class BranchConflictError(BranchError):
code = "BRANCH_CONFLICT"
class BranchStore:
"""分支树仓:每会话一棵树,JSON 持久化(原子写)+ 并发锁。
结构: {sessions: {session_id: {tree_root, active_node, branches: {id: {...}}}}}
"""
def __init__(self, path: str | None = None) -> None:
self.path = path or os.environ.get(
"APS_BRANCH_PATH", "server/data/branches.json")
self._lock = threading.RLock()
self._data = self._load()
def _load(self) -> dict[str, Any]:
try:
with open(self.path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict) and isinstance(data.get("sessions"), dict):
return data
except (FileNotFoundError, json.JSONDecodeError, TypeError):
pass
return {"sessions": {}}
def _write(self) -> None:
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path) or ".", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False, sort_keys=True)
os.replace(tmp, self.path)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
# ---------------- 会话树 ----------------
def ensure_session(self, session_id: str) -> None:
"""确保会话有根分支(惰性创建;幂等)。"""
with self._lock:
if session_id in self._data["sessions"]:
return
root = {
"id": _uid(), "name": "主干", "parentId": None,
"status": _ACTIVE, "createdAt": _now(), "checkpointId": None,
}
self._data["sessions"][session_id] = {
"tree_root": root["id"], "active_node": root["id"],
"branches": {root["id"]: root},
}
self._write()
def _tree(self, session_id: str) -> dict[str, Any]:
tree = self._data["sessions"].get(session_id)
if tree is None:
raise BranchNotFoundError(f"session {session_id!r} has no branch tree")
return tree
def _branch(self, session_id: str, branch_id: str) -> dict[str, Any]:
tree = self._tree(session_id)
branch = tree["branches"].get(branch_id)
if branch is None:
raise BranchNotFoundError(f"branch {branch_id!r} not found in session {session_id!r}")
return branch
# ---------------- 查询 ----------------
def tree(self, session_id: str) -> dict[str, Any]:
"""返回会话分支树(只读快照)。"""
with self._lock:
self.ensure_session(session_id)
tree = self._tree(session_id)
return {
"treeRoot": tree["tree_root"],
"activeNode": tree["active_node"],
"branches": list(tree["branches"].values()),
}
def active(self, session_id: str) -> str:
"""当前活动分支 id。"""
with self._lock:
self.ensure_session(session_id)
return self._tree(session_id)["active_node"]
# ---------------- 生命周期 ----------------
def fork(self, session_id: str, name: str, checkpoint_id: str | None = None) -> dict[str, Any]:
"""从当前活动分支派生新分支(继承活动分支的 checkpoint 锚点)。"""
with self._lock:
self.ensure_session(session_id)
tree = self._tree(session_id)
parent_id = tree["active_node"]
parent = tree["branches"][parent_id]
branch = {
"id": _uid(), "name": name.strip() or "新分支", "parentId": parent_id,
"status": _ACTIVE, "createdAt": _now(),
"checkpointId": checkpoint_id or parent.get("checkpointId"),
}
tree["branches"][branch["id"]] = branch
tree["active_node"] = branch["id"]
self._write()
return branch
def rename(self, session_id: str, branch_id: str, name: str) -> dict[str, Any]:
with self._lock:
branch = self._branch(session_id, branch_id)
if branch["status"] != _ACTIVE:
raise BranchConflictError("只能重命名活动状态的分支")
branch["name"] = name.strip() or branch["name"]
self._write()
return branch
def switch(self, session_id: str, branch_id: str) -> dict[str, Any]:
"""切换活动分支;返回目标分支(含 checkpointId 供成对状态恢复)。"""
with self._lock:
tree = self._tree(session_id)
branch = self._branch(session_id, branch_id)
if branch["status"] == _DISCARDED:
raise BranchConflictError("已丢弃的分支不可切换")
tree["active_node"] = branch_id
self._write()
return branch
def discard(self, session_id: str, branch_id: str) -> dict[str, Any]:
"""丢弃分支(仅允许丢弃非主干、非当前活动分支;标记 DISCARDED 保留审计)。"""
with self._lock:
tree = self._tree(session_id)
branch = self._branch(session_id, branch_id)
if branch_id == tree["tree_root"]:
raise BranchConflictError("主干分支不可丢弃")
if branch_id == tree["active_node"]:
raise BranchConflictError("当前活动分支不可丢弃,请先切换")
if branch["status"] == _MERGED:
raise BranchConflictError("已合并的分支不可再次丢弃")
branch["status"] = _DISCARDED
self._write()
return branch
def merge(self, session_id: str, source_id: str, target_id: str) -> dict[str, Any]:
"""合并:source 并入 target(target 保持活动);source 标记 MERGED。"""
with self._lock:
tree = self._tree(session_id)
source = self._branch(session_id, source_id)
target = self._branch(session_id, target_id)
if source["status"] == _MERGED or source["status"] == _DISCARDED:
raise BranchConflictError("源分支不可合并(已合并/已丢弃)")
if target["status"] == _DISCARDED:
raise BranchConflictError("目标分支已丢弃")
if source_id == target_id:
raise BranchConflictError("不能合并到自身")
source["status"] = _MERGED
source["mergedInto"] = target_id
target["checkpointId"] = source.get("checkpointId") or target.get("checkpointId")
tree["active_node"] = target_id
self._write()
return {"source": source, "target": target}
# ---------------- ??????????? 51 ??merge diff ??? ----------------
def _safe_projection(world: dict[str, Any]) -> dict[str, Any]:
"""?????????????/???????????????????????"""
from server.aps_domain.flex import capacity_analysis, flex_gantt_view
from server.aps_domain.views import gantt_view, world_summary
empty = {
"summary": {"hasVersion": False},
"gantt": {"lines": [], "workstations": [], "workOrders": []},
"flexGantt": {"versionNo": None, "sortMode": None, "zones": [],
"equipment": [], "workOrders": []},
"flexCapacity": {"pools": [], "bottleneckPool": None},
}
try:
proj = {
"summary": world_summary(world),
"gantt": gantt_view(world),
"flexGantt": flex_gantt_view(world),
"flexCapacity": capacity_analysis(world),
}
for key, fallback in empty.items():
if not isinstance(proj.get(key), dict):
proj[key] = fallback
return proj
except Exception: # noqa: BLE001 - 投影降级边界:任何视图异常都不阻断合并
return empty
def _num(value: Any) -> int | float | None:
"""JSON ????????NaN/??? ? None???????? diff??"""
if isinstance(value, bool) or value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, float):
import math
return value if math.isfinite(value) else None
return None
def _pair(src: Any, dst: Any) -> dict[str, Any]:
"""{source, target, delta} ?????????? delta=None?"""
s, t = _num(src), _num(dst)
delta = (t - s) if (s is not None and t is not None) else None
return {"source": s, "target": t, "delta": delta}
def compute_world_diff(source_world: dict[str, Any] | None,
target_world: dict[str, Any] | None,
*, source_label: str = "source",
target_label: str = "target") -> dict[str, Any]:
"""??????? checkpoint ??????????????? 51 ???
- ????????????world_summary / gantt_view / flex_gantt_view /
capacity_analysis??? 54 ????????????????????
- ?? checkpoint / ???? ? ???????????diff ???? JSON?
- ???????version / kpi / operationTimeDiffs / flexVersion /
capacityPools / changed??????????????
"""
import copy
sa = _safe_projection(copy.deepcopy(source_world or {}))
ta = _safe_projection(copy.deepcopy(target_world or {}))
# ---- ?????versionNo / status / KPI? ----
version = {
"sourceVersionNo": sa["summary"].get("versionNo"),
"targetVersionNo": ta["summary"].get("versionNo"),
"sourceStatus": sa["summary"].get("status"),
"targetStatus": ta["summary"].get("status"),
"changed": (sa["summary"].get("versionNo") != ta["summary"].get("versionNo")
or sa["summary"].get("status") != ta["summary"].get("status")),
}
kpi = {
"workOrderCount": _pair(sa["summary"].get("woCount"), ta["summary"].get("woCount")),
"conflictCount": _pair(sa["summary"].get("conflictCount"), ta["summary"].get("conflictCount")),
"totalTardiness": _pair(sa["summary"].get("totalTardiness"), ta["summary"].get("totalTardiness")),
"avgUtilization": _pair(sa["summary"].get("avgUtilization"), ta["summary"].get("avgUtilization")),
}
# ---- ???????????? + ????????????? start/end? ----
def _fixed_key(row: dict[str, Any]) -> tuple[str, Any]:
return ("fixed", row.get("orderNo"), row.get("operationName"), row.get("id"))
def _flex_key(row: dict[str, Any]) -> tuple[str, Any]:
return ("flex", row.get("orderNo"), row.get("woNo"),
row.get("operationName"), row.get("seq"))
def _collect(proj: dict[str, Any], key_fn) -> dict[tuple[str, Any], dict[str, Any]]:
out: dict[tuple[str, Any], dict[str, Any]] = {}
for track_rows, track in ((proj["gantt"].get("workOrders") or [], "fixed"),
(proj["flexGantt"].get("workOrders") or [], "flex")):
for row in track_rows:
out[key_fn(row)] = dict(row)
return out
src_rows = _collect(sa, _fixed_key)
src_rows.update(_collect(sa, _flex_key))
dst_rows = _collect(ta, _fixed_key)
dst_rows.update(_collect(ta, _flex_key))
op_diffs: list[dict[str, Any]] = []
for key in sorted(set(src_rows) | set(dst_rows)):
a, b = src_rows.get(key), dst_rows.get(key)
if a is None or b is None:
changed = True # ??/??????????
flags = {"added": a is None, "deleted": b is None}
else:
changed = (a.get("start") != b.get("start") or a.get("end") != b.get("end")
or a.get("equipmentCode") != b.get("equipmentCode")
or a.get("moldCode") != b.get("moldCode"))
flags = {}
if changed:
op_diffs.append({
"track": key[0],
"orderNo": b.get("orderNo") if b else a.get("orderNo"),
"operationName": b.get("operationName") if b else a.get("operationName"),
"source": {"start": (a or {}).get("start"), "end": (a or {}).get("end"),
"equipment": (a or {}).get("equipmentCode"),
"mold": (a or {}).get("moldCode")},
"target": {"start": (b or {}).get("start"), "end": (b or {}).get("end"),
"equipment": (b or {}).get("equipmentCode"),
"mold": (b or {}).get("moldCode")},
**flags,
})
op_diffs.sort(key=lambda d: (d["track"], str(d["orderNo"]), str(d["operationName"])))
truncated = len(op_diffs) > 20
operation_time_diffs = {
"count": len(op_diffs),
"orders": op_diffs[:20],
"truncated": truncated,
}
# ---- ?????? ----
flex_version = {
"sourceVersionNo": sa["flexGantt"].get("versionNo"),
"targetVersionNo": ta["flexGantt"].get("versionNo"),
"sourceSortMode": sa["flexGantt"].get("sortMode"),
"targetSortMode": ta["flexGantt"].get("sortMode"),
"changed": (sa["flexGantt"].get("versionNo") != ta["flexGantt"].get("versionNo")
or sa["flexGantt"].get("sortMode") != ta["flexGantt"].get("sortMode")),
}
# ---- ????? ----
src_pools = {p.get("operationCode"): p for p in (sa["flexCapacity"].get("pools") or [])}
dst_pools = {p.get("operationCode"): p for p in (ta["flexCapacity"].get("pools") or [])}
pool_rows: list[dict[str, Any]] = []
for code in sorted(set(src_pools) | set(dst_pools)):
a, b = src_pools.get(code), dst_pools.get(code)
changed = (a is None or b is None
or a.get("equipmentCount") != b.get("equipmentCount")
or a.get("dailyCapacity") != b.get("dailyCapacity"))
if changed:
pool_rows.append({
"operationCode": code,
"operationName": (b or a or {}).get("operationName"),
"source": {"equipmentCount": (a or {}).get("equipmentCount"),
"dailyCapacity": (a or {}).get("dailyCapacity")},
"target": {"equipmentCount": (b or {}).get("equipmentCount"),
"dailyCapacity": (b or {}).get("dailyCapacity")},
})
capacity_pools = {
"sourceCount": len(src_pools),
"targetCount": len(dst_pools),
"changed": len(pool_rows) > 0 or len(src_pools) != len(dst_pools),
"pools": pool_rows[:20],
}
changed = (version["changed"] or flex_version["changed"]
or capacity_pools["changed"] or operation_time_diffs["count"] > 0)
return {
"computedAt": _now(),
"source": {"label": source_label, "checkpointId": None},
"target": {"label": target_label, "checkpointId": None},
"version": version,
"kpi": kpi,
"operationTimeDiffs": operation_time_diffs,
"flexVersion": flex_version,
"capacityPools": capacity_pools,
"changed": changed,
}
_branches: dict[tuple[str, str], BranchStore] = {}
_branches_lock = threading.Lock()
def get_branches(tenant_uuid: str = "platform", world_key: str = "default") -> BranchStore:
"""按租户/项目隔离的分支树仓实例。"""
key = (_safe_scope(tenant_uuid), _safe_scope(world_key))
with _branches_lock:
store = _branches.get(key)
if store is None:
base = os.environ.get("APS_BRANCH_DIR", "server/data")
store = BranchStore(os.path.join(base, f"branches-{key[0]}-{key[1]}.json"))
_branches[key] = store
return store
def reset_branch_stores() -> None:
"""清空分支树单例(测试/重配置用)。"""
with _branches_lock:
_branches.clear()