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()
|