611 lines
25 KiB
Python
611 lines
25 KiB
Python
# ============================================================
|
||
# 分层 Plan 运行时(moduleId: core-plan-runtime, 可重生 ✅)
|
||
# plan.md §3.1:L0-L3 共用不可变骨架;重生只追加同 planId 的新版本。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import threading
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from typing import Any, ClassVar, Literal
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
|
||
|
||
|
||
PlanLayer = Literal["L0", "L1", "L2", "L3"]
|
||
PlanStatus = Literal["DRAFT", "APPROVED", "RUNNING", "DONE", "FAILED", "SUPERSEDED"]
|
||
PlanCreator = Literal["LLM", "USER", "SYSTEM"]
|
||
|
||
_LAYER_PARENT: dict[PlanLayer, PlanLayer | None] = {
|
||
"L0": None,
|
||
"L1": "L0",
|
||
"L2": "L1",
|
||
"L3": "L2",
|
||
}
|
||
|
||
|
||
class PlanNode(BaseModel):
|
||
"""One immutable version of an L0-L3 governance plan."""
|
||
|
||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||
|
||
planId: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
||
layer: PlanLayer
|
||
parentId: str | None
|
||
version: int = Field(ge=1)
|
||
regenCount: int = Field(ge=0)
|
||
inputsHash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||
status: PlanStatus
|
||
payload: JsonValue
|
||
evidenceRefs: tuple[str, ...] # schema required
|
||
createdAt: str = Field(min_length=1)
|
||
createdBy: PlanCreator
|
||
|
||
@model_validator(mode="after")
|
||
def validate_parent_shape(self) -> "PlanNode":
|
||
expected = _LAYER_PARENT[self.layer]
|
||
if expected is None and self.parentId is not None:
|
||
raise ValueError("L0 plan nodes cannot have a parent")
|
||
if expected is not None and self.parentId is None:
|
||
raise ValueError(f"{self.layer} plan nodes require a {expected} parent")
|
||
return self
|
||
|
||
|
||
class PlanStoreError(RuntimeError):
|
||
code = "PLAN_STORE_ERROR"
|
||
|
||
|
||
class PlanNotFoundError(PlanStoreError):
|
||
code = "PLAN_NOT_FOUND"
|
||
|
||
|
||
class PlanConflictError(PlanStoreError):
|
||
code = "PLAN_CONFLICT"
|
||
|
||
|
||
class PlanInputMismatchError(PlanConflictError):
|
||
code = "PLAN_INPUTS_HASH_MISMATCH"
|
||
|
||
|
||
class PlanRegenerationFusedError(PlanConflictError):
|
||
code = "PLAN_REGEN_FUSED"
|
||
|
||
|
||
class PlanTransitionError(PlanStoreError):
|
||
code = "PLAN_TRANSITION_INVALID"
|
||
|
||
|
||
class PlanParentError(PlanStoreError):
|
||
code = "PLAN_PARENT_INVALID"
|
||
|
||
|
||
class PlanStoreCorruptError(PlanStoreError):
|
||
code = "PLAN_STORE_CORRUPT"
|
||
|
||
|
||
def canonical_inputs_hash(inputs: JsonValue) -> str:
|
||
"""Return a stable SHA-256 for JSON inputs, independent of object key order."""
|
||
encoded = json.dumps(
|
||
inputs,
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
separators=(",", ":"),
|
||
allow_nan=False,
|
||
).encode("utf-8")
|
||
return hashlib.sha256(encoded).hexdigest()
|
||
|
||
|
||
def _now_iso() -> str:
|
||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||
|
||
|
||
class PlanStore:
|
||
"""Tenant-scoped append-only PlanNode version log backed by atomic JSON writes."""
|
||
|
||
schema_version = "1.0"
|
||
|
||
def __init__(self, path: str, *, max_regen: int = 3) -> None:
|
||
if max_regen < 0:
|
||
raise ValueError("max_regen must be non-negative")
|
||
self.path = path
|
||
self.max_regen = max_regen
|
||
self._lock = threading.RLock()
|
||
self._branch_views: dict[str, dict[str, Any]] = {} # branch-anchored plan snapshots (matrix 52)
|
||
self._plans = self._load()
|
||
self._active_view: str | None = None # active branch plan view (None = no anchor)
|
||
|
||
def _load(self) -> dict[str, list[dict[str, JsonValue]]]:
|
||
try:
|
||
with open(self.path, "r", encoding="utf-8") as handle:
|
||
document = json.load(handle)
|
||
except FileNotFoundError:
|
||
return {}
|
||
except (json.JSONDecodeError, OSError) as exc:
|
||
raise PlanStoreCorruptError(f"cannot read Plan store: {exc}") from exc
|
||
|
||
if not isinstance(document, dict):
|
||
raise PlanStoreCorruptError("Plan store document must be a JSON object")
|
||
if document.get("schemaVersion") != self.schema_version or not isinstance(document.get("plans"), dict):
|
||
raise PlanStoreCorruptError("unsupported or malformed Plan store document")
|
||
|
||
loaded: dict[str, list[dict[str, JsonValue]]] = {}
|
||
try:
|
||
for plan_id, raw_versions in document["plans"].items():
|
||
if not isinstance(raw_versions, list) or not raw_versions:
|
||
raise ValueError(f"plan {plan_id!r} has no versions")
|
||
nodes = [PlanNode.model_validate(item) for item in raw_versions]
|
||
if any(node.planId != plan_id for node in nodes):
|
||
raise ValueError(f"plan key {plan_id!r} does not match stored node")
|
||
if [node.version for node in nodes] != list(range(1, len(nodes) + 1)):
|
||
raise ValueError(f"plan {plan_id!r} has a non-contiguous version history")
|
||
first = nodes[0]
|
||
if any(
|
||
(node.layer, node.parentId, node.inputsHash)
|
||
!= (first.layer, first.parentId, first.inputsHash)
|
||
for node in nodes[1:]
|
||
):
|
||
raise ValueError(f"plan {plan_id!r} changes immutable lineage fields")
|
||
regen_counts = [node.regenCount for node in nodes]
|
||
# regenCount 单调不减,且每段重生(regenCount=k)以 version 递增推进,
|
||
# 状态流转(transition_status)在同一 regenCount 下追加版本——两者均合法
|
||
if regen_counts != sorted(regen_counts):
|
||
raise ValueError(f"plan {plan_id!r} has an invalid regeneration history")
|
||
loaded[plan_id] = [node.model_dump(mode="json") for node in nodes]
|
||
except (TypeError, ValueError) as exc:
|
||
raise PlanStoreCorruptError(f"invalid Plan store history: {exc}") from exc
|
||
# 分支锚定 plan 快照(矩阵 52 行;宽容解析:坏记录跳过,不阻塞 plan 主数据加载)
|
||
raw_views = document.get("branchViews")
|
||
if raw_views is not None and not isinstance(raw_views, dict):
|
||
raise PlanStoreCorruptError("invalid Plan store branch views")
|
||
if isinstance(raw_views, dict):
|
||
for branch_id, record in raw_views.items():
|
||
if not isinstance(record, dict) or not isinstance(record.get("plans"), dict):
|
||
continue
|
||
plan_versions: dict[str, int] = {}
|
||
for plan_id, version in record["plans"].items():
|
||
try:
|
||
plan_versions[str(plan_id)] = int(version)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
self._branch_views[str(branch_id)] = {
|
||
**record, "plans": plan_versions,
|
||
}
|
||
return loaded
|
||
|
||
def _write(self) -> None:
|
||
directory = os.path.dirname(self.path) or "."
|
||
os.makedirs(directory, exist_ok=True)
|
||
fd, temporary = tempfile.mkstemp(dir=directory, suffix=".tmp")
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||
json.dump(
|
||
{"schemaVersion": self.schema_version, "plans": self._plans,
|
||
"branchViews": self._branch_views},
|
||
handle,
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
separators=(",", ":"),
|
||
)
|
||
os.replace(temporary, self.path)
|
||
except BaseException:
|
||
if os.path.exists(temporary):
|
||
os.unlink(temporary)
|
||
raise
|
||
|
||
@staticmethod
|
||
def _copy_node(raw: dict[str, JsonValue]) -> PlanNode:
|
||
return PlanNode.model_validate(copy.deepcopy(raw))
|
||
|
||
def _latest_unlocked(self, plan_id: str) -> PlanNode:
|
||
versions = self._plans.get(plan_id)
|
||
if not versions:
|
||
raise PlanNotFoundError(f"plan {plan_id!r} does not exist")
|
||
return self._copy_node(versions[-1])
|
||
|
||
# ---------------- 分支 plan 视图(矩阵 52 行:plan 版本随分支切换回退) ----------------
|
||
def _visible_versions_unlocked(self) -> dict[str, int] | None:
|
||
"""当前分支视图可见的 plan_id -> version;None 表示无分支锚定(保持既有全局行为)。"""
|
||
if self._active_view is None:
|
||
return None
|
||
record = self._branch_views.get(self._active_view)
|
||
if record is None:
|
||
return {}
|
||
return dict(record.get("plans") or {})
|
||
|
||
def _latest_visible_unlocked(self, plan_id: str) -> PlanNode:
|
||
"""分支视图下的最新节点:视图外 plan / 超出版本范围 → PlanNotFoundError。"""
|
||
view = self._visible_versions_unlocked()
|
||
if view is None:
|
||
return self._latest_unlocked(plan_id)
|
||
version = view.get(plan_id)
|
||
versions = self._plans.get(plan_id)
|
||
if version is None or not versions or version < 1 or version > len(versions):
|
||
raise PlanNotFoundError(
|
||
f"plan {plan_id!r} is not visible in branch view {self._active_view!r}"
|
||
)
|
||
return self._copy_node(versions[version - 1])
|
||
|
||
def _view_record_unlocked(self) -> dict[str, Any] | None:
|
||
"""活动分支的视图记录(惰性建表);无分支锚定返回 None。"""
|
||
if self._active_view is None:
|
||
return None
|
||
record = self._branch_views.get(self._active_view)
|
||
if record is None:
|
||
record = {
|
||
"branchId": self._active_view,
|
||
"checkpointId": None,
|
||
"capturedAt": _now_iso(),
|
||
"plans": {},
|
||
}
|
||
self._branch_views[self._active_view] = record
|
||
record.setdefault("plans", {})
|
||
return record
|
||
|
||
def _record_view_write_unlocked(self, plan_id: str, version: int) -> None:
|
||
"""把新/流转版本记录到活动分支视图(分支内创建/流转可见且可回退)。"""
|
||
record = self._view_record_unlocked()
|
||
if record is not None:
|
||
record["plans"][plan_id] = version
|
||
|
||
def _rollback_view_write_unlocked(self, plan_id: str, old_version: int | None) -> None:
|
||
"""写盘失败时回滚活动分支视图中的版本指针。"""
|
||
if self._active_view is None:
|
||
return
|
||
record = self._branch_views.get(self._active_view)
|
||
if record is None:
|
||
return
|
||
plans = record.setdefault("plans", {})
|
||
if old_version is None:
|
||
plans.pop(plan_id, None)
|
||
else:
|
||
plans[plan_id] = old_version
|
||
|
||
def plans_snapshot(self, branch_id: str, *, checkpoint_id: str | None = None) -> dict[str, Any]:
|
||
"""捕获当前可见 plan 状态为分支锚定快照(矩阵 52 行:可审计、可回退)。
|
||
|
||
记录每个可见 plan 的当前 version;随后 restore_for_branch 可把视图回退到此状态。
|
||
返回结构稳定(branchId/checkpointId/capturedAt/plans)且持久化于 branchViews。
|
||
"""
|
||
with self._lock:
|
||
view = self._visible_versions_unlocked()
|
||
if view is None:
|
||
plan_versions = {
|
||
plan_id: len(raw)
|
||
for plan_id, raw in self._plans.items() if raw
|
||
}
|
||
else:
|
||
plan_versions = dict(view)
|
||
record: dict[str, Any] = {
|
||
"branchId": branch_id,
|
||
"checkpointId": checkpoint_id,
|
||
"capturedAt": _now_iso(),
|
||
"plans": plan_versions,
|
||
}
|
||
self._branch_views[branch_id] = record
|
||
try:
|
||
self._write()
|
||
except BaseException:
|
||
self._branch_views.pop(branch_id, None)
|
||
raise
|
||
return copy.deepcopy(record)
|
||
|
||
def restore_for_branch(self, branch_id: str, *, checkpoint_id: str | None = None,
|
||
archived: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
"""分支切换时激活该分支的 plan 视图(矩阵 52 行:plan 版本随分支切换回退)。
|
||
|
||
- 已有快照 → 直接激活(含此前在该分支上创建/流转的版本);
|
||
- 无快照但提供 checkpoint 归档(conversationSide.plans: plan_id -> 版本列表)
|
||
→ 按归档末版本物化视图;
|
||
- 无任何锚定数据 → 关闭视图,保持既有全局行为(无锚定兼容)。
|
||
"""
|
||
with self._lock:
|
||
record = self._branch_views.get(branch_id)
|
||
if record is None:
|
||
if not isinstance(archived, dict):
|
||
self._active_view = None # 无锚定:不启用分支过滤
|
||
return {"branchId": branch_id, "checkpointId": checkpoint_id,
|
||
"capturedAt": None, "restored": False, "planCount": 0}
|
||
plan_versions: dict[str, int] = {}
|
||
for plan_id, versions in archived.items():
|
||
if isinstance(versions, (list, tuple)) and versions:
|
||
try:
|
||
plan_versions[str(plan_id)] = len(versions)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
record = {
|
||
"branchId": branch_id,
|
||
"checkpointId": checkpoint_id,
|
||
"capturedAt": _now_iso(),
|
||
"plans": plan_versions,
|
||
"restoredFrom": "checkpoint-archive",
|
||
}
|
||
self._branch_views[branch_id] = record
|
||
try:
|
||
self._write()
|
||
except BaseException:
|
||
self._branch_views.pop(branch_id, None)
|
||
raise
|
||
self._active_view = branch_id
|
||
return {"branchId": branch_id,
|
||
"checkpointId": record.get("checkpointId"),
|
||
"capturedAt": record.get("capturedAt"),
|
||
"restored": True,
|
||
"planCount": len(record.get("plans") or {})}
|
||
|
||
def active_view(self) -> dict[str, Any] | None:
|
||
"""当前活动分支 plan 视图(None = 无锚定,全局视图)。"""
|
||
with self._lock:
|
||
if self._active_view is None:
|
||
return None
|
||
record = self._branch_views.get(self._active_view)
|
||
return copy.deepcopy(record) if record is not None else None
|
||
|
||
def plan_views(self) -> dict[str, Any]:
|
||
"""全部已持久化的分支 plan 快照(审计/诊断)。"""
|
||
with self._lock:
|
||
return copy.deepcopy(self._branch_views)
|
||
|
||
def _validate_parent_unlocked(self, layer: PlanLayer, parent_id: str | None) -> None:
|
||
expected = _LAYER_PARENT[layer]
|
||
if expected is None:
|
||
if parent_id is not None:
|
||
raise PlanParentError("L0 plan nodes cannot have a parent")
|
||
return
|
||
if parent_id is None:
|
||
raise PlanParentError(f"{layer} plan nodes require a {expected} parent")
|
||
try:
|
||
parent = self._latest_visible_unlocked(parent_id)
|
||
except PlanNotFoundError as exc:
|
||
raise PlanParentError(f"parent plan {parent_id!r} does not exist") from exc
|
||
if parent.layer != expected:
|
||
raise PlanParentError(
|
||
f"{layer} plan nodes require a {expected} parent, got {parent.layer}"
|
||
)
|
||
|
||
def create(
|
||
self,
|
||
*,
|
||
layer: PlanLayer,
|
||
parent_id: str | None,
|
||
inputs: JsonValue,
|
||
payload: JsonValue,
|
||
status: PlanStatus = "DRAFT",
|
||
evidence_refs: tuple[str, ...] | list[str] = (),
|
||
created_by: PlanCreator = "SYSTEM",
|
||
plan_id: str | None = None,
|
||
) -> PlanNode:
|
||
"""Create version 1 for a new Plan; existing planIds can never be overwritten."""
|
||
with self._lock:
|
||
resolved_id = plan_id or f"plan_{uuid.uuid4().hex}"
|
||
if resolved_id in self._plans:
|
||
raise PlanConflictError(f"plan {resolved_id!r} already exists")
|
||
self._validate_parent_unlocked(layer, parent_id)
|
||
node = PlanNode(
|
||
planId=resolved_id,
|
||
layer=layer,
|
||
parentId=parent_id,
|
||
version=1,
|
||
regenCount=0,
|
||
inputsHash=canonical_inputs_hash(inputs),
|
||
status=status,
|
||
payload=copy.deepcopy(payload),
|
||
evidenceRefs=tuple(evidence_refs),
|
||
createdAt=_now_iso(),
|
||
createdBy=created_by,
|
||
)
|
||
self._plans[resolved_id] = [node.model_dump(mode="json")]
|
||
old_view: int | None = None
|
||
if self._active_view is not None:
|
||
record = self._view_record_unlocked()
|
||
old_view = record["plans"].get(resolved_id)
|
||
self._record_view_write_unlocked(resolved_id, 1)
|
||
try:
|
||
self._write()
|
||
except BaseException:
|
||
del self._plans[resolved_id]
|
||
self._rollback_view_write_unlocked(resolved_id, old_view)
|
||
raise
|
||
return node.model_copy(deep=True)
|
||
|
||
def regenerate(
|
||
self,
|
||
plan_id: str,
|
||
*,
|
||
inputs: JsonValue,
|
||
expected_inputs_hash: str,
|
||
payload: JsonValue,
|
||
status: PlanStatus = "DRAFT",
|
||
evidence_refs: tuple[str, ...] | list[str] | None = None,
|
||
created_by: PlanCreator = "SYSTEM",
|
||
) -> PlanNode:
|
||
"""Append a new version after hash validation; never mutate previous versions."""
|
||
with self._lock:
|
||
latest = self._latest_visible_unlocked(plan_id)
|
||
actual_hash = canonical_inputs_hash(inputs)
|
||
if expected_inputs_hash != actual_hash:
|
||
raise PlanInputMismatchError("expectedInputsHash does not match the supplied inputs")
|
||
if actual_hash != latest.inputsHash:
|
||
raise PlanInputMismatchError("regeneration inputs differ from the latest Plan version")
|
||
if latest.regenCount >= self.max_regen:
|
||
raise PlanRegenerationFusedError(
|
||
f"plan {plan_id!r} reached the regeneration limit ({self.max_regen})"
|
||
)
|
||
|
||
node = PlanNode(
|
||
planId=latest.planId,
|
||
layer=latest.layer,
|
||
parentId=latest.parentId,
|
||
version=latest.version + 1,
|
||
regenCount=latest.regenCount + 1,
|
||
inputsHash=latest.inputsHash,
|
||
status=status,
|
||
payload=copy.deepcopy(payload),
|
||
evidenceRefs=(latest.evidenceRefs if evidence_refs is None else tuple(evidence_refs)),
|
||
createdAt=_now_iso(),
|
||
createdBy=created_by,
|
||
)
|
||
self._plans[plan_id].append(node.model_dump(mode="json"))
|
||
old_view: int | None = None
|
||
if self._active_view is not None:
|
||
record = self._view_record_unlocked()
|
||
old_view = record["plans"].get(plan_id)
|
||
self._record_view_write_unlocked(plan_id, node.version)
|
||
try:
|
||
self._write()
|
||
except BaseException:
|
||
self._plans[plan_id].pop()
|
||
self._rollback_view_write_unlocked(plan_id, old_view)
|
||
raise
|
||
return node.model_copy(deep=True)
|
||
|
||
# 状态流转:DRAFT -> APPROVED / DRAFT -> FAILED(驳回)/ RUNNING / DONE
|
||
_ALLOWED_TRANSITIONS: ClassVar[dict[PlanStatus, frozenset[PlanStatus]]] = {
|
||
"DRAFT": frozenset({"APPROVED", "FAILED", "RUNNING", "SUPERSEDED"}),
|
||
"RUNNING": frozenset({"DONE", "FAILED", "APPROVED"}),
|
||
"APPROVED": frozenset({"DONE", "RUNNING", "SUPERSEDED"}),
|
||
"FAILED": frozenset({"DRAFT", "RUNNING", "SUPERSEDED"}),
|
||
"DONE": frozenset({"SUPERSEDED"}),
|
||
"SUPERSEDED": frozenset(),
|
||
}
|
||
|
||
def transition_status(
|
||
self,
|
||
plan_id: str,
|
||
*,
|
||
new_status: PlanStatus,
|
||
created_by: PlanCreator = "USER",
|
||
note: str = "",
|
||
) -> PlanNode:
|
||
"""Plan 状态流转(矩阵 110 行:批准/驳回审计)。
|
||
|
||
追加新版本(version+1,regenCount 不变,inputsHash/payload 不变)承载状态变化,
|
||
保证历史不可变;非法流转抛 PlanTransitionError。
|
||
Returns: 新状态版本节点。
|
||
"""
|
||
with self._lock:
|
||
latest = self._latest_visible_unlocked(plan_id)
|
||
allowed = self._ALLOWED_TRANSITIONS.get(latest.status, frozenset())
|
||
if new_status not in allowed:
|
||
raise PlanTransitionError(
|
||
f"plan {plan_id!r} status transition {latest.status} -> {new_status} not allowed"
|
||
)
|
||
payload = copy.deepcopy(latest.payload)
|
||
if note:
|
||
payload.setdefault("_note", []).append({
|
||
"status": new_status, "by": created_by, "note": note,
|
||
})
|
||
node = PlanNode(
|
||
planId=latest.planId,
|
||
layer=latest.layer,
|
||
parentId=latest.parentId,
|
||
version=latest.version + 1,
|
||
regenCount=latest.regenCount,
|
||
inputsHash=latest.inputsHash,
|
||
status=new_status,
|
||
payload=payload,
|
||
evidenceRefs=(latest.evidenceRefs + (f"status:{new_status}",)),
|
||
createdAt=_now_iso(),
|
||
createdBy=created_by,
|
||
)
|
||
self._plans[plan_id].append(node.model_dump(mode="json"))
|
||
old_view: int | None = None
|
||
if self._active_view is not None:
|
||
record = self._view_record_unlocked()
|
||
old_view = record["plans"].get(plan_id)
|
||
self._record_view_write_unlocked(plan_id, node.version)
|
||
try:
|
||
self._write()
|
||
except BaseException:
|
||
self._plans[plan_id].pop()
|
||
self._rollback_view_write_unlocked(plan_id, old_view)
|
||
raise
|
||
return node.model_copy(deep=True)
|
||
|
||
def latest(self, plan_id: str) -> PlanNode:
|
||
with self._lock:
|
||
return self._latest_visible_unlocked(plan_id)
|
||
|
||
def node_at(self, plan_id: str, version: int) -> PlanNode:
|
||
"""时间线回放:取指定版本的不可变节点(矩阵 110 行)。
|
||
|
||
版本号必须落在历史范围内;任何版本内容均不可变。
|
||
"""
|
||
with self._lock:
|
||
view = self._visible_versions_unlocked()
|
||
if view is not None and view.get(plan_id) is None:
|
||
raise PlanNotFoundError(
|
||
f"plan {plan_id!r} is not visible in branch view {self._active_view!r}"
|
||
)
|
||
versions = self._plans.get(plan_id)
|
||
if not versions:
|
||
raise PlanNotFoundError(f"plan {plan_id!r} does not exist")
|
||
max_version = len(versions)
|
||
if view is not None:
|
||
max_version = min(max_version, view[plan_id])
|
||
if version < 1 or version > max_version:
|
||
raise PlanNotFoundError(f"plan {plan_id!r} has no version {version}")
|
||
return self._copy_node(versions[version - 1])
|
||
|
||
def replay_verify(self, plan_id: str, version: int, inputs: JsonValue) -> PlanNode:
|
||
"""L2 单节点/L3 动作级重放校验(矩阵 110 行)。
|
||
|
||
给定输入重新计算 inputsHash,与指定版本的 inputsHash 比对:
|
||
一致则返回该版本节点(可复算重放的先决);不一致抛 PlanInputMismatchError。
|
||
"""
|
||
node = self.node_at(plan_id, version)
|
||
actual_hash = canonical_inputs_hash(inputs)
|
||
if actual_hash != node.inputsHash:
|
||
raise PlanInputMismatchError(
|
||
f"plan {plan_id!r} v{version} inputsHash mismatch: "
|
||
f"expected {node.inputsHash}, got {actual_hash}"
|
||
)
|
||
return node
|
||
|
||
|
||
def versions(self, plan_id: str) -> list[PlanNode]:
|
||
with self._lock:
|
||
view = self._visible_versions_unlocked()
|
||
if view is not None and view.get(plan_id) is None:
|
||
raise PlanNotFoundError(
|
||
f"plan {plan_id!r} is not visible in branch view {self._active_view!r}"
|
||
)
|
||
versions = self._plans.get(plan_id)
|
||
if not versions:
|
||
raise PlanNotFoundError(f"plan {plan_id!r} does not exist")
|
||
if view is not None:
|
||
versions = versions[:view[plan_id]]
|
||
return [self._copy_node(raw) for raw in versions]
|
||
|
||
|
||
_plan_stores: dict[tuple[str, str], PlanStore] = {}
|
||
_plan_stores_lock = threading.Lock()
|
||
|
||
|
||
def get_plan_store() -> PlanStore:
|
||
"""Return the append-only Plan store for the current tenant and project world."""
|
||
from server.auth.context import get_identity
|
||
from server.state.store import get_store
|
||
|
||
identity = get_identity()
|
||
world = get_store()
|
||
key = (identity.tenant_uuid, world.world_key)
|
||
with _plan_stores_lock:
|
||
store = _plan_stores.get(key)
|
||
if store is None:
|
||
path = os.path.join(os.path.dirname(world.path) or ".", "plans.json")
|
||
max_regen = int(os.environ.get("APS_PLAN_REGEN_LIMIT") or "3")
|
||
store = PlanStore(path, max_regen=max_regen)
|
||
_plan_stores[key] = store
|
||
return store
|
||
|
||
|
||
def reset_plan_stores() -> None:
|
||
"""Clear scoped singleton instances; intended for process reconfiguration and tests."""
|
||
with _plan_stores_lock:
|
||
_plan_stores.clear()
|