# ============================================================ # 分层 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()