generated from coulomb/repo-seed
Implement local runtime persistence and policy gates
This commit is contained in:
@@ -19,12 +19,19 @@ from .models import (
|
||||
MemoryGraph,
|
||||
MemoryKind,
|
||||
MemoryNode,
|
||||
MemoryPath,
|
||||
MemoryPathState,
|
||||
MemoryPhase,
|
||||
PolicyDecision,
|
||||
ProfileExecutionPlan,
|
||||
ProfileIntent,
|
||||
ReviewDecision,
|
||||
ReviewRecord,
|
||||
)
|
||||
from .paths import abandon_path, branch_path, compact_path, create_path, merge_path, path_event
|
||||
from .policy import POLICY_OPERATION_POINTS, MemoryOperation, make_review_record
|
||||
from .planner import plan_profile_execution
|
||||
from .runtime import PhaseMemoryRuntime
|
||||
|
||||
__all__ = [
|
||||
"ActivationPlan",
|
||||
@@ -37,11 +44,24 @@ __all__ = [
|
||||
"MemoryGraph",
|
||||
"MemoryKind",
|
||||
"MemoryNode",
|
||||
"MemoryPath",
|
||||
"MemoryPathState",
|
||||
"MemoryPhase",
|
||||
"PolicyDecision",
|
||||
"ProfileExecutionPlan",
|
||||
"ProfileIntent",
|
||||
"ReviewDecision",
|
||||
"ReviewRecord",
|
||||
"PhaseMemoryRuntime",
|
||||
"POLICY_OPERATION_POINTS",
|
||||
"MemoryOperation",
|
||||
"abandon_path",
|
||||
"branch_path",
|
||||
"compact_path",
|
||||
"create_path",
|
||||
"graph_from_markitect",
|
||||
"merge_path",
|
||||
"make_review_record",
|
||||
"plan_activation",
|
||||
"plan_compaction",
|
||||
"plan_phase_transition",
|
||||
@@ -49,6 +69,7 @@ __all__ = [
|
||||
"plan_refresh",
|
||||
"plan_retention",
|
||||
"profile_from_markitect",
|
||||
"path_event",
|
||||
]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .models import MemoryEdge, MemoryEvent, MemoryNode, PolicyDecision, ProfileIntent
|
||||
from .models import Diagnostic, MemoryEdge, MemoryEvent, MemoryGraph, MemoryNode, MemoryPath, PolicyDecision, ProfileIntent
|
||||
|
||||
LOCAL_STORE_SCHEMA = "phase_memory.local_store.v1"
|
||||
|
||||
|
||||
class InMemoryMemoryGraphStore:
|
||||
@@ -63,6 +67,160 @@ class InMemoryMemoryEventLog:
|
||||
return events
|
||||
|
||||
|
||||
class FileBackedMemoryGraphStore:
|
||||
"""Versioned JSON-backed local graph store.
|
||||
|
||||
Layout:
|
||||
- `phase-memory.json`
|
||||
- `profiles/<profile-id>.json`
|
||||
- `nodes/<node-id>.json`
|
||||
- `edges/<edge-id>.json`
|
||||
- `paths/<path-id>.json`
|
||||
- `activations/`
|
||||
- `audit.jsonl`
|
||||
"""
|
||||
|
||||
def __init__(self, root: str | Path) -> None:
|
||||
self.root = Path(root)
|
||||
self.profiles_dir = self.root / "profiles"
|
||||
self.nodes_dir = self.root / "nodes"
|
||||
self.edges_dir = self.root / "edges"
|
||||
self.paths_dir = self.root / "paths"
|
||||
self.activations_dir = self.root / "activations"
|
||||
self.audit_path = self.root / "audit.jsonl"
|
||||
self._ensure_layout()
|
||||
|
||||
def save_profile(self, profile: ProfileIntent) -> ProfileIntent:
|
||||
_write_json(self.profiles_dir / f"{_safe_name(profile.profile_id)}.json", profile.to_dict())
|
||||
return profile
|
||||
|
||||
def get_profile(self, profile_id: str) -> ProfileIntent:
|
||||
return ProfileIntent.from_mapping(_read_json(self.profiles_dir / f"{_safe_name(profile_id)}.json"))
|
||||
|
||||
def save_node(self, node: MemoryNode) -> MemoryNode:
|
||||
_write_json(self.nodes_dir / f"{_safe_name(node.node_id)}.json", node.to_dict())
|
||||
return node
|
||||
|
||||
def get_node(self, node_id: str) -> MemoryNode:
|
||||
return MemoryNode.from_mapping(_read_json(self.nodes_dir / f"{_safe_name(node_id)}.json"))
|
||||
|
||||
def list_nodes(self, *, kind: str | None = None) -> list[MemoryNode]:
|
||||
nodes = [MemoryNode.from_mapping(_read_json(path)) for path in sorted(self.nodes_dir.glob("*.json"))]
|
||||
if kind:
|
||||
nodes = [node for node in nodes if node.kind == kind]
|
||||
return sorted(nodes, key=lambda node: node.node_id)
|
||||
|
||||
def save_edge(self, edge: MemoryEdge) -> MemoryEdge:
|
||||
_write_json(self.edges_dir / f"{_safe_name(edge.edge_id)}.json", edge.to_dict())
|
||||
return edge
|
||||
|
||||
def list_edges(self, *, source: str | None = None, target: str | None = None) -> list[MemoryEdge]:
|
||||
edges = [MemoryEdge.from_mapping(_read_json(path)) for path in sorted(self.edges_dir.glob("*.json"))]
|
||||
if source:
|
||||
edges = [edge for edge in edges if edge.source == source]
|
||||
if target:
|
||||
edges = [edge for edge in edges if edge.target == target]
|
||||
return sorted(edges, key=lambda edge: edge.edge_id)
|
||||
|
||||
def save_path(self, path: MemoryPath) -> MemoryPath:
|
||||
_write_json(self.paths_dir / f"{_safe_name(path.path_id)}.json", path.to_dict())
|
||||
return path
|
||||
|
||||
def get_path(self, path_id: str) -> MemoryPath:
|
||||
return MemoryPath.from_mapping(_read_json(self.paths_dir / f"{_safe_name(path_id)}.json"))
|
||||
|
||||
def list_paths(self) -> list[MemoryPath]:
|
||||
return [MemoryPath.from_mapping(_read_json(path)) for path in sorted(self.paths_dir.glob("*.json"))]
|
||||
|
||||
def export_graph(self, *, graph_id: str = "local", events: list[MemoryEvent] | None = None) -> MemoryGraph:
|
||||
return MemoryGraph(
|
||||
graph_id=graph_id,
|
||||
nodes=tuple(self.list_nodes()),
|
||||
edges=tuple(self.list_edges()),
|
||||
events=tuple(events or ()),
|
||||
metadata={"store_schema_version": LOCAL_STORE_SCHEMA, "store_path": str(self.root)},
|
||||
)
|
||||
|
||||
def repair_diagnostics(self, *, events: list[MemoryEvent] | None = None) -> tuple[Diagnostic, ...]:
|
||||
diagnostics: list[Diagnostic] = []
|
||||
node_ids = {node.node_id for node in self.list_nodes()}
|
||||
event_ids = {event.event_id for event in events or ()}
|
||||
for edge in self.list_edges():
|
||||
if edge.source not in node_ids:
|
||||
diagnostics.append(Diagnostic("error", "missing_edge_source", "Edge source does not reference a node.", edge.edge_id, {"source": edge.source}))
|
||||
if edge.target not in node_ids:
|
||||
diagnostics.append(Diagnostic("error", "missing_edge_target", "Edge target does not reference a node.", edge.edge_id, {"target": edge.target}))
|
||||
for path in self.list_paths():
|
||||
for event_id in path.event_ids:
|
||||
if event_id not in event_ids:
|
||||
diagnostics.append(Diagnostic("warn", "orphaned_path_event", "Path references an event not present in the event log.", path.path_id, {"event_id": event_id}))
|
||||
return tuple(diagnostics)
|
||||
|
||||
def _ensure_layout(self) -> None:
|
||||
for directory in (self.root, self.profiles_dir, self.nodes_dir, self.edges_dir, self.paths_dir, self.activations_dir):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
metadata_path = self.root / "phase-memory.json"
|
||||
if not metadata_path.exists():
|
||||
_write_json(metadata_path, {"schema_version": LOCAL_STORE_SCHEMA})
|
||||
|
||||
|
||||
class JsonlMemoryEventLog:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.touch(exist_ok=True)
|
||||
|
||||
def append(self, event: MemoryEvent) -> MemoryEvent:
|
||||
if any(existing.event_id == event.event_id for existing in self.list_events()):
|
||||
raise ValueError(f"Duplicate memory event id: {event.event_id}")
|
||||
with self.path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event.to_dict(), sort_keys=True, separators=(",", ":")) + "\n")
|
||||
return event
|
||||
|
||||
def list_events(self, *, kind: str | None = None) -> list[MemoryEvent]:
|
||||
events: list[MemoryEvent] = []
|
||||
for data in self._iter_valid_event_dicts():
|
||||
event = MemoryEvent.from_mapping(data)
|
||||
if kind is None or event.kind == kind:
|
||||
events.append(event)
|
||||
return sorted(events, key=lambda event: (event.timestamp, event.event_id))
|
||||
|
||||
def replay_graph(self, store: FileBackedMemoryGraphStore, *, graph_id: str = "local") -> MemoryGraph:
|
||||
return store.export_graph(graph_id=graph_id, events=self.list_events())
|
||||
|
||||
def diagnostics(self) -> tuple[Diagnostic, ...]:
|
||||
diagnostics: list[Diagnostic] = []
|
||||
seen: set[str] = set()
|
||||
for line_number, raw in self._iter_lines():
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
diagnostics.append(Diagnostic("error", "malformed_event_log_line", "Event log line is not valid JSON.", f"line:{line_number}", {"error": str(exc)}))
|
||||
continue
|
||||
schema = data.get("schema_version")
|
||||
if schema and schema != "markitect.memory.event.v1":
|
||||
diagnostics.append(Diagnostic("warn", "unknown_event_schema", "Event declares an unknown schema version.", f"line:{line_number}", {"schema_version": schema}))
|
||||
event_id = str(data.get("id") or data.get("event_id") or "")
|
||||
if event_id in seen:
|
||||
diagnostics.append(Diagnostic("error", "duplicate_event_id", "Event log contains a duplicate event id.", f"line:{line_number}", {"event_id": event_id}))
|
||||
if event_id:
|
||||
seen.add(event_id)
|
||||
return tuple(diagnostics)
|
||||
|
||||
def _iter_valid_event_dicts(self):
|
||||
for _, raw in self._iter_lines():
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
yield data
|
||||
|
||||
def _iter_lines(self):
|
||||
for line_number, raw in enumerate(self.path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||
if raw.strip():
|
||||
yield line_number, raw
|
||||
|
||||
|
||||
class NoopContextPackageCompiler:
|
||||
def compile_selection(self, selection: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -85,3 +243,32 @@ class RecordingAuditSink:
|
||||
stored = dict(event)
|
||||
self.events.append(stored)
|
||||
return {"recorded": True, "index": len(self.events) - 1, "event": stored}
|
||||
|
||||
|
||||
class JsonlAuditSink:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.touch(exist_ok=True)
|
||||
|
||||
def record(self, event: dict[str, Any]) -> dict[str, Any]:
|
||||
stored = dict(event)
|
||||
with self.path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(stored, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
with self.path.open(encoding="utf-8") as handle:
|
||||
index = max(sum(1 for _ in handle) - 1, 0)
|
||||
return {"recorded": True, "index": index, "event": stored}
|
||||
|
||||
|
||||
def _safe_name(identifier: str) -> str:
|
||||
safe = "".join(char if char.isalnum() or char in ("-", "_", ".") else "_" for char in identifier)
|
||||
return safe or "anonymous"
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
196
src/phase_memory/cli.py
Normal file
196
src/phase_memory/cli.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Command line interface for phase-memory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
from .adapters import FileBackedMemoryGraphStore, JsonlAuditSink, JsonlMemoryEventLog
|
||||
from .runtime import PhaseMemoryRuntime
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
runtime = PhaseMemoryRuntime()
|
||||
envelope = args.func(args, runtime)
|
||||
if args.format == "summary":
|
||||
_print_summary(envelope)
|
||||
else:
|
||||
print(json.dumps(envelope, indent=2, sort_keys=True))
|
||||
return 0 if envelope.get("valid") else 1
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="phase-memory")
|
||||
parser.set_defaults(func=_missing_command, format="json")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
profile = subparsers.add_parser("profile", help="Profile operations")
|
||||
profile_subparsers = profile.add_subparsers(dest="profile_command")
|
||||
profile_plan = profile_subparsers.add_parser("plan", help="Plan a Markitect memory profile")
|
||||
profile_plan.add_argument("profile", type=Path)
|
||||
_add_format(profile_plan)
|
||||
profile_plan.set_defaults(func=_profile_plan)
|
||||
|
||||
graph = subparsers.add_parser("graph", help="Graph operations")
|
||||
graph_subparsers = graph.add_subparsers(dest="graph_command")
|
||||
|
||||
lifecycle = graph_subparsers.add_parser("lifecycle", help="Plan graph lifecycle actions")
|
||||
lifecycle.add_argument("graph", type=Path)
|
||||
lifecycle.add_argument("--stale-after-days", type=int)
|
||||
lifecycle.add_argument("--delete-after-days", type=int)
|
||||
lifecycle.add_argument("--refresh-digest", action="append", default=[], metavar="NODE_ID=DIGEST")
|
||||
lifecycle.add_argument("--compact-node", action="append", default=[])
|
||||
_add_format(lifecycle)
|
||||
lifecycle.set_defaults(func=_graph_lifecycle)
|
||||
|
||||
activate = graph_subparsers.add_parser("activate", help="Plan graph activation selection")
|
||||
activate.add_argument("graph", type=Path)
|
||||
activate.add_argument("--max-items", type=int, required=True)
|
||||
activate.add_argument("--max-tokens", type=int, required=True)
|
||||
activate.add_argument("--profile-id")
|
||||
activate.add_argument("--priority-node", action="append", default=[])
|
||||
activate.add_argument("--no-events", action="store_true")
|
||||
_add_format(activate)
|
||||
activate.set_defaults(func=_graph_activate)
|
||||
|
||||
store = subparsers.add_parser("store", help="Local file-backed store operations")
|
||||
store_subparsers = store.add_subparsers(dest="store_command")
|
||||
|
||||
store_import = store_subparsers.add_parser("import", help="Import profile and graph fixtures into a local store")
|
||||
store_import.add_argument("--store", type=Path, required=True)
|
||||
store_import.add_argument("--profile", type=Path)
|
||||
store_import.add_argument("--graph", type=Path)
|
||||
_add_format(store_import)
|
||||
store_import.set_defaults(func=_store_import)
|
||||
|
||||
store_export = store_subparsers.add_parser("export", help="Export a local store as a graph envelope")
|
||||
store_export.add_argument("--store", type=Path, required=True)
|
||||
store_export.add_argument("--graph-id", default="local")
|
||||
_add_format(store_export)
|
||||
store_export.set_defaults(func=_store_export)
|
||||
|
||||
store_repair = store_subparsers.add_parser("repair", help="Report local store repair diagnostics")
|
||||
store_repair.add_argument("--store", type=Path, required=True)
|
||||
_add_format(store_repair)
|
||||
store_repair.set_defaults(func=_store_repair)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _add_format(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--format", choices=("json", "summary"), default="json")
|
||||
|
||||
|
||||
def _profile_plan(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
return runtime.plan_profile(_read_json(args.profile), source_ref=str(args.profile))
|
||||
|
||||
|
||||
def _graph_lifecycle(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
return runtime.plan_lifecycle(
|
||||
_read_json(args.graph),
|
||||
source_ref=str(args.graph),
|
||||
stale_after_days=args.stale_after_days,
|
||||
delete_after_days=args.delete_after_days,
|
||||
refresh_digests=_parse_digest_args(args.refresh_digest),
|
||||
compact_node_ids=tuple(args.compact_node),
|
||||
)
|
||||
|
||||
|
||||
def _graph_activate(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
return runtime.plan_activation(
|
||||
_read_json(args.graph),
|
||||
source_ref=str(args.graph),
|
||||
max_items=args.max_items,
|
||||
max_tokens=args.max_tokens,
|
||||
profile_id=args.profile_id,
|
||||
priority_node_ids=tuple(args.priority_node),
|
||||
include_events=not args.no_events,
|
||||
)
|
||||
|
||||
|
||||
def _store_import(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
runtime = _runtime_for_store(args.store)
|
||||
results = []
|
||||
if args.profile:
|
||||
results.append(runtime.import_profile(_read_json(args.profile), source_ref=str(args.profile)))
|
||||
if args.graph:
|
||||
results.append(runtime.import_graph(_read_json(args.graph), source_ref=str(args.graph)))
|
||||
return {
|
||||
"schema_version": "phase_memory.store.import.v1",
|
||||
"operation": "store.import",
|
||||
"valid": all(result.get("valid") for result in results),
|
||||
"dry_run": False,
|
||||
"store": str(args.store),
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
def _store_export(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
return _runtime_for_store(args.store).export_graph(graph_id=args.graph_id, source_ref=str(args.store))
|
||||
|
||||
|
||||
def _store_repair(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
return _runtime_for_store(args.store).repair_diagnostics(source_ref=str(args.store))
|
||||
|
||||
|
||||
def _missing_command(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
raise SystemExit("Missing command. Use --help for usage.")
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _parse_digest_args(items: list[str]) -> dict[str, str]:
|
||||
digests: dict[str, str] = {}
|
||||
for item in items:
|
||||
if "=" not in item:
|
||||
raise SystemExit(f"Expected --refresh-digest NODE_ID=DIGEST, got: {item}")
|
||||
node_id, digest = item.split("=", 1)
|
||||
digests[node_id] = digest
|
||||
return digests
|
||||
|
||||
|
||||
def _runtime_for_store(path: Path) -> PhaseMemoryRuntime:
|
||||
store = FileBackedMemoryGraphStore(path)
|
||||
return PhaseMemoryRuntime(
|
||||
graph_store=store,
|
||||
event_log=JsonlMemoryEventLog(path / "events.jsonl"),
|
||||
audit_sink=JsonlAuditSink(path / "audit.jsonl"),
|
||||
)
|
||||
|
||||
|
||||
def _print_summary(envelope: dict[str, Any]) -> None:
|
||||
subject = envelope.get("subject", {})
|
||||
print(f"{envelope.get('operation')} {subject.get('kind')}:{subject.get('id')}")
|
||||
print(f"valid={str(envelope.get('valid')).lower()} dry_run={str(envelope.get('dry_run')).lower()}")
|
||||
diagnostics = envelope.get("diagnostics", [])
|
||||
if diagnostics:
|
||||
print(f"diagnostics={len(diagnostics)}")
|
||||
data = envelope.get("data", {})
|
||||
if "plan" in data and data["plan"]:
|
||||
plan = data["plan"]
|
||||
print(f"ready={str(plan.get('ready')).lower()}")
|
||||
print(f"capabilities={','.join(plan.get('capabilities', []))}")
|
||||
if "dry_run_actions" in data:
|
||||
print(f"actions={len(data.get('dry_run_actions', []))}")
|
||||
if envelope.get("operation") == "store.import":
|
||||
print(f"results={len(envelope.get('results', []))}")
|
||||
if data.get("graph"):
|
||||
graph = data["graph"]
|
||||
print(f"nodes={len(graph.get('nodes', []))}")
|
||||
print(f"edges={len(graph.get('edges', []))}")
|
||||
print(f"events={len(graph.get('events', []))}")
|
||||
activation = data.get("activation_plan")
|
||||
if activation:
|
||||
print(f"selected_nodes={len(activation.get('selected_node_ids', []))}")
|
||||
print(f"omitted={len(activation.get('omitted', []))}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -53,6 +53,18 @@ class LifecycleActionKind(str, Enum):
|
||||
NO_OP = "no_op"
|
||||
|
||||
|
||||
class MemoryPathState(str, Enum):
|
||||
ACTIVE = "active"
|
||||
MERGED = "merged"
|
||||
ABANDONED = "abandoned"
|
||||
COMPACTED = "compacted"
|
||||
|
||||
|
||||
class ReviewDecision(str, Enum):
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Diagnostic:
|
||||
severity: str
|
||||
@@ -279,6 +291,51 @@ class MemoryEvent:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryPath:
|
||||
path_id: str
|
||||
parent_path_id: str = ""
|
||||
event_ids: tuple[str, ...] = ()
|
||||
state: MemoryPathState = MemoryPathState.ACTIVE
|
||||
merged_into: str = ""
|
||||
abandoned_reason: str = ""
|
||||
compacted_summary_id: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: str = field(default_factory=utc_now_iso)
|
||||
updated_at: str = field(default_factory=utc_now_iso)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: dict[str, Any]) -> "MemoryPath":
|
||||
return cls(
|
||||
path_id=str(data.get("id") or data.get("path_id") or ""),
|
||||
parent_path_id=str(data.get("parent_path_id") or ""),
|
||||
event_ids=tuple(str(item) for item in data.get("event_ids", ())),
|
||||
state=MemoryPathState(str(data.get("state") or MemoryPathState.ACTIVE.value)),
|
||||
merged_into=str(data.get("merged_into") or ""),
|
||||
abandoned_reason=str(data.get("abandoned_reason") or ""),
|
||||
compacted_summary_id=str(data.get("compacted_summary_id") or ""),
|
||||
metadata=dict(data.get("metadata") or {}),
|
||||
created_at=str(data.get("created_at") or utc_now_iso()),
|
||||
updated_at=str(data.get("updated_at") or utc_now_iso()),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return compact_dict(
|
||||
{
|
||||
"id": self.path_id,
|
||||
"parent_path_id": self.parent_path_id,
|
||||
"event_ids": list(self.event_ids),
|
||||
"state": self.state,
|
||||
"merged_into": self.merged_into,
|
||||
"abandoned_reason": self.abandoned_reason,
|
||||
"compacted_summary_id": self.compacted_summary_id,
|
||||
"metadata": self.metadata,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryGraph:
|
||||
graph_id: str
|
||||
@@ -326,6 +383,52 @@ class PolicyDecision:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReviewRecord:
|
||||
review_id: str
|
||||
reviewed_action_id: str
|
||||
reviewer: str
|
||||
decision: ReviewDecision
|
||||
reason: str = ""
|
||||
obligations: tuple[str, ...] = ()
|
||||
source_digests: dict[str, str] = field(default_factory=dict)
|
||||
timestamp: str = field(default_factory=utc_now_iso)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: dict[str, Any]) -> "ReviewRecord":
|
||||
return cls(
|
||||
review_id=str(data.get("id") or data.get("review_id") or ""),
|
||||
reviewed_action_id=str(data.get("reviewed_action_id") or data.get("action_id") or ""),
|
||||
reviewer=str(data.get("reviewer") or data.get("reviewer_id") or "local"),
|
||||
decision=ReviewDecision(str(data.get("decision") or ReviewDecision.REJECTED.value)),
|
||||
reason=str(data.get("reason") or ""),
|
||||
obligations=tuple(str(item) for item in data.get("obligations", ())),
|
||||
source_digests={str(key): str(value) for key, value in dict(data.get("source_digests") or {}).items()},
|
||||
timestamp=str(data.get("timestamp") or utc_now_iso()),
|
||||
metadata=dict(data.get("metadata") or {}),
|
||||
)
|
||||
|
||||
@property
|
||||
def approved(self) -> bool:
|
||||
return self.decision == ReviewDecision.APPROVED
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return compact_dict(
|
||||
{
|
||||
"id": self.review_id,
|
||||
"reviewed_action_id": self.reviewed_action_id,
|
||||
"reviewer": self.reviewer,
|
||||
"decision": self.decision,
|
||||
"reason": self.reason,
|
||||
"obligations": list(self.obligations),
|
||||
"source_digests": self.source_digests,
|
||||
"timestamp": self.timestamp,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LifecycleAction:
|
||||
action: LifecycleActionKind
|
||||
|
||||
47
src/phase_memory/paths.py
Normal file
47
src/phase_memory/paths.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Structured conversational path helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from .models import MemoryEvent, MemoryPath, MemoryPathState
|
||||
from .utils import stable_digest, utc_now_iso
|
||||
|
||||
|
||||
def create_path(path_id: str, *, event_ids: tuple[str, ...] = (), metadata: dict | None = None) -> MemoryPath:
|
||||
return MemoryPath(path_id=path_id, event_ids=event_ids, metadata=dict(metadata or {}))
|
||||
|
||||
|
||||
def branch_path(parent: MemoryPath, path_id: str, *, event_ids: tuple[str, ...] = ()) -> MemoryPath:
|
||||
return MemoryPath(
|
||||
path_id=path_id,
|
||||
parent_path_id=parent.path_id,
|
||||
event_ids=event_ids,
|
||||
metadata={"branched_from": parent.path_id},
|
||||
)
|
||||
|
||||
|
||||
def merge_path(path: MemoryPath, target_path_id: str) -> MemoryPath:
|
||||
return replace(path, state=MemoryPathState.MERGED, merged_into=target_path_id, updated_at=utc_now_iso())
|
||||
|
||||
|
||||
def abandon_path(path: MemoryPath, reason: str) -> MemoryPath:
|
||||
return replace(path, state=MemoryPathState.ABANDONED, abandoned_reason=reason, updated_at=utc_now_iso())
|
||||
|
||||
|
||||
def compact_path(path: MemoryPath, summary_node_id: str) -> MemoryPath:
|
||||
return replace(path, state=MemoryPathState.COMPACTED, compacted_summary_id=summary_node_id, updated_at=utc_now_iso())
|
||||
|
||||
|
||||
def path_event(path: MemoryPath, kind: str, *, metadata: dict | None = None) -> MemoryEvent:
|
||||
event_id = f"path-event:{stable_digest([path.path_id, kind, path.event_ids, metadata or {}])}"
|
||||
return MemoryEvent(
|
||||
event_id=event_id,
|
||||
kind=kind,
|
||||
metadata={
|
||||
"path_id": path.path_id,
|
||||
"parent_path_id": path.parent_path_id,
|
||||
"path_state": path.state.value,
|
||||
**dict(metadata or {}),
|
||||
},
|
||||
)
|
||||
145
src/phase_memory/policy.py
Normal file
145
src/phase_memory/policy.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Policy, review, redaction, and audit helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from .models import Diagnostic, MemoryNode, PolicyDecision, ReviewDecision, ReviewRecord
|
||||
from .utils import stable_digest, utc_now_iso
|
||||
|
||||
AUDIT_EVENT_SCHEMA = "phase_memory.audit.event.v1"
|
||||
REDACTED = "[REDACTED]"
|
||||
|
||||
|
||||
class MemoryOperation(str, Enum):
|
||||
PROFILE_IMPORT = "profile.import"
|
||||
GRAPH_IMPORT = "graph.import"
|
||||
NODE_READ = "node.read"
|
||||
EVENT_READ = "event.read"
|
||||
PROFILE_PLAN = "profile.plan"
|
||||
LIFECYCLE_PLAN = "graph.lifecycle.plan"
|
||||
ACTIVATION_PLAN = "graph.activation.plan"
|
||||
PACKAGE_COMPILE = "package.compile"
|
||||
LIFECYCLE_APPLY = "lifecycle.apply"
|
||||
STABILIZATION = "memory.stabilize"
|
||||
COMPACTION = "memory.compact"
|
||||
REFRESH = "memory.refresh"
|
||||
DELETE_REQUEST = "memory.delete_request"
|
||||
ARCHIVE = "memory.archive"
|
||||
GRAPH_EXPORT = "graph.export"
|
||||
STORE_REPAIR = "store.repair.diagnostics"
|
||||
|
||||
|
||||
POLICY_OPERATION_POINTS = tuple(operation.value for operation in MemoryOperation)
|
||||
|
||||
|
||||
def make_review_record(
|
||||
*,
|
||||
reviewed_action_id: str,
|
||||
reviewer: str,
|
||||
approved: bool,
|
||||
reason: str = "",
|
||||
obligations: tuple[str, ...] = (),
|
||||
source_digests: dict[str, str] | None = None,
|
||||
) -> ReviewRecord:
|
||||
decision = ReviewDecision.APPROVED if approved else ReviewDecision.REJECTED
|
||||
review_id = f"review:{stable_digest([reviewed_action_id, reviewer, decision.value, reason, source_digests or {}])}"
|
||||
return ReviewRecord(
|
||||
review_id=review_id,
|
||||
reviewed_action_id=reviewed_action_id,
|
||||
reviewer=reviewer,
|
||||
decision=decision,
|
||||
reason=reason,
|
||||
obligations=obligations,
|
||||
source_digests=dict(source_digests or {}),
|
||||
)
|
||||
|
||||
|
||||
def audit_event(
|
||||
*,
|
||||
operation_id: str,
|
||||
operation: str,
|
||||
subject: dict[str, str],
|
||||
policy_decision: PolicyDecision,
|
||||
dry_run: bool,
|
||||
source_ref: str,
|
||||
actor: str = "local",
|
||||
planned_action_id: str = "",
|
||||
profile_id: str = "",
|
||||
graph_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": AUDIT_EVENT_SCHEMA,
|
||||
"operation_id": operation_id,
|
||||
"operation": operation,
|
||||
"subject": dict(subject),
|
||||
"allowed": policy_decision.allowed,
|
||||
"profile_id": profile_id,
|
||||
"graph_id": graph_id,
|
||||
"policy_decision": policy_decision.to_dict(),
|
||||
"dry_run": dry_run,
|
||||
"planned_action_id": planned_action_id,
|
||||
"actor": actor,
|
||||
"timestamp": utc_now_iso(),
|
||||
"source": {"ref": source_ref},
|
||||
}
|
||||
|
||||
|
||||
def evaluate_activation_node(
|
||||
node: MemoryNode,
|
||||
*,
|
||||
required_labels: tuple[str, ...] = (),
|
||||
denied_labels: tuple[str, ...] = (),
|
||||
trust_zone: str = "",
|
||||
secrets_allowed: bool = True,
|
||||
approved_reauthorizations: tuple[str, ...] = (),
|
||||
require_fresh: bool = False,
|
||||
) -> tuple[bool, tuple[str, ...]]:
|
||||
labels = set(_list_value(node.policy.get("labels")) + _list_value(node.metadata.get("labels")))
|
||||
reasons: list[str] = []
|
||||
for label in required_labels:
|
||||
if label not in labels:
|
||||
reasons.append(f"missing_label:{label}")
|
||||
for label in denied_labels:
|
||||
if label in labels:
|
||||
reasons.append(f"denied_label:{label}")
|
||||
node_trust_zone = str(node.policy.get("trust_zone") or node.metadata.get("trust_zone") or "")
|
||||
if trust_zone and node_trust_zone and node_trust_zone != trust_zone:
|
||||
reasons.append(f"trust_zone:{node_trust_zone}")
|
||||
if not secrets_allowed and bool(node.policy.get("secret") or node.metadata.get("secret")):
|
||||
reasons.append("secret_denied")
|
||||
reauthorization = str(node.policy.get("reauthorization") or "")
|
||||
if reauthorization and reauthorization not in approved_reauthorizations:
|
||||
reasons.append(f"reauthorization:{reauthorization}")
|
||||
if require_fresh and str(node.freshness.get("status") or "").lower() == "stale":
|
||||
reasons.append("freshness:stale")
|
||||
return not reasons, tuple(reasons)
|
||||
|
||||
|
||||
def redacted_node(node: MemoryNode, *, reasons: tuple[str, ...]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": node.node_id,
|
||||
"kind": node.kind,
|
||||
"text": REDACTED,
|
||||
"policy": REDACTED,
|
||||
"reasons": list(reasons),
|
||||
}
|
||||
|
||||
|
||||
def policy_denial_diagnostic(node: MemoryNode, reasons: tuple[str, ...]) -> Diagnostic:
|
||||
return Diagnostic(
|
||||
"warn",
|
||||
"activation_policy_denied",
|
||||
"Memory node was omitted from activation by policy.",
|
||||
node.node_id,
|
||||
{"reasons": list(reasons), "redacted": redacted_node(node, reasons=reasons)},
|
||||
)
|
||||
|
||||
|
||||
def _list_value(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
return [str(item) for item in value]
|
||||
471
src/phase_memory/runtime.py
Normal file
471
src/phase_memory/runtime.py
Normal file
@@ -0,0 +1,471 @@
|
||||
"""Local runtime facade for deterministic phase-memory operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .activation import activation_action, plan_activation
|
||||
from .adapters import (
|
||||
AllowAllPolicyGateway,
|
||||
InMemoryMemoryEventLog,
|
||||
InMemoryMemoryGraphStore,
|
||||
NoopContextPackageCompiler,
|
||||
RecordingAuditSink,
|
||||
)
|
||||
from .contracts import ContractIngressResult, graph_from_markitect, profile_from_markitect
|
||||
from .lifecycle import plan_compaction, plan_refresh, plan_retention
|
||||
from .models import (
|
||||
Diagnostic,
|
||||
LifecycleAction,
|
||||
LifecycleActionKind,
|
||||
LifecycleState,
|
||||
MemoryGraph,
|
||||
MemoryNode,
|
||||
MemoryPhase,
|
||||
PolicyDecision,
|
||||
ProfileIntent,
|
||||
ReviewRecord,
|
||||
)
|
||||
from .planner import plan_profile_execution
|
||||
from .policy import audit_event, evaluate_activation_node, make_review_record, policy_denial_diagnostic, redacted_node
|
||||
from .ports import AuditSink, ContextPackageCompiler, MemoryEventLog, MemoryGraphStore, PolicyGateway
|
||||
from .utils import compact_dict, stable_digest, to_plain
|
||||
|
||||
RUNTIME_ENVELOPE_SCHEMA = "phase_memory.runtime.envelope.v1"
|
||||
PACKAGE_REQUEST_SCHEMA = "phase_memory.package_request.v1"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhaseMemoryRuntime:
|
||||
"""Dependency-light local runtime facade.
|
||||
|
||||
The facade coordinates existing pure planners and local adapters while
|
||||
keeping every public operation dry-run and JSON-serializable by default.
|
||||
"""
|
||||
|
||||
graph_store: MemoryGraphStore = field(default_factory=InMemoryMemoryGraphStore)
|
||||
event_log: MemoryEventLog = field(default_factory=InMemoryMemoryEventLog)
|
||||
package_compiler: ContextPackageCompiler = field(default_factory=NoopContextPackageCompiler)
|
||||
policy_gateway: PolicyGateway = field(default_factory=AllowAllPolicyGateway)
|
||||
audit_sink: AuditSink = field(default_factory=RecordingAuditSink)
|
||||
|
||||
def import_profile(self, data: dict[str, Any], *, source_ref: str = "mapping") -> dict[str, Any]:
|
||||
result = profile_from_markitect(data)
|
||||
if result.valid:
|
||||
self.graph_store.save_profile(result.value)
|
||||
return self._contract_envelope("profile.import", result, source_ref=source_ref)
|
||||
|
||||
def import_graph(self, data: dict[str, Any], *, source_ref: str = "mapping") -> dict[str, Any]:
|
||||
result = graph_from_markitect(data)
|
||||
if result.valid:
|
||||
graph: MemoryGraph = result.value
|
||||
for node in graph.nodes:
|
||||
self.graph_store.save_node(node)
|
||||
for edge in graph.edges:
|
||||
self.graph_store.save_edge(edge)
|
||||
for event in graph.events:
|
||||
self.event_log.append(event)
|
||||
return self._contract_envelope("graph.import", result, source_ref=source_ref)
|
||||
|
||||
def plan_profile(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
source_ref: str = "mapping",
|
||||
available_adapters: Iterable[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = profile_from_markitect(data)
|
||||
profile: ProfileIntent | None = result.value if result.valid else None
|
||||
plan = plan_profile_execution(profile, available_adapters=available_adapters) if profile else None
|
||||
diagnostics = list(result.diagnostics)
|
||||
if plan is not None:
|
||||
diagnostics.extend(plan.diagnostics)
|
||||
return self._envelope(
|
||||
"profile.plan",
|
||||
subject_kind="memory_profile",
|
||||
subject_id=result.subject_id,
|
||||
valid=result.valid and plan is not None,
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={
|
||||
"profile": result.value.to_dict() if hasattr(result.value, "to_dict") else result.value,
|
||||
"plan": plan.to_dict() if plan else None,
|
||||
},
|
||||
)
|
||||
|
||||
def plan_lifecycle(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
source_ref: str = "mapping",
|
||||
stale_after_days: int | None = None,
|
||||
delete_after_days: int | None = None,
|
||||
refresh_digests: dict[str, str] | None = None,
|
||||
compact_node_ids: tuple[str, ...] = (),
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = graph_from_markitect(data)
|
||||
graph: MemoryGraph | None = result.value if result.valid else None
|
||||
actions: list[LifecycleAction] = []
|
||||
|
||||
if graph is not None:
|
||||
actions.extend(
|
||||
plan_retention(
|
||||
graph.nodes,
|
||||
stale_after_days=stale_after_days,
|
||||
delete_after_days=delete_after_days,
|
||||
now=now,
|
||||
)
|
||||
)
|
||||
if refresh_digests:
|
||||
actions.extend(plan_refresh(graph.nodes, source_digest_by_node_id=refresh_digests))
|
||||
if compact_node_ids:
|
||||
by_id = graph.node_by_id()
|
||||
compact_nodes = [by_id[node_id] for node_id in compact_node_ids if node_id in by_id]
|
||||
if compact_nodes:
|
||||
actions.append(plan_compaction(compact_nodes))
|
||||
|
||||
return self._envelope(
|
||||
"graph.lifecycle.plan",
|
||||
subject_kind="memory_graph",
|
||||
subject_id=result.subject_id,
|
||||
valid=result.valid,
|
||||
diagnostics=result.diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={
|
||||
"graph_id": result.subject_id,
|
||||
"dry_run_actions": [action.to_dict() for action in actions],
|
||||
"parameters": compact_dict(
|
||||
{
|
||||
"stale_after_days": stale_after_days,
|
||||
"delete_after_days": delete_after_days,
|
||||
"refresh_digests": refresh_digests or {},
|
||||
"compact_node_ids": list(compact_node_ids),
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def plan_activation(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
source_ref: str = "mapping",
|
||||
max_items: int,
|
||||
max_tokens: int,
|
||||
profile_id: str | None = None,
|
||||
priority_node_ids: tuple[str, ...] = (),
|
||||
include_events: bool = True,
|
||||
policy_context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = graph_from_markitect(data)
|
||||
graph: MemoryGraph | None = result.value if result.valid else None
|
||||
policy_denials: list[dict[str, Any]] = []
|
||||
diagnostics = list(result.diagnostics)
|
||||
if graph is not None and policy_context:
|
||||
graph, policy_denials, policy_diagnostics = _policy_filtered_graph(graph, policy_context)
|
||||
diagnostics.extend(policy_diagnostics)
|
||||
plan = (
|
||||
plan_activation(
|
||||
graph,
|
||||
max_items=max_items,
|
||||
max_tokens=max_tokens,
|
||||
profile_id=profile_id,
|
||||
priority_node_ids=priority_node_ids,
|
||||
include_events=include_events,
|
||||
)
|
||||
if graph
|
||||
else None
|
||||
)
|
||||
if plan is not None:
|
||||
diagnostics.extend(plan.diagnostics)
|
||||
|
||||
return self._envelope(
|
||||
"graph.activation.plan",
|
||||
subject_kind="memory_graph",
|
||||
subject_id=result.subject_id,
|
||||
valid=result.valid and plan is not None,
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={
|
||||
"activation_plan": plan.to_dict() if plan else None,
|
||||
"activation_action": activation_action(plan).to_dict() if plan else None,
|
||||
"package_request": self.package_request(plan.selection) if plan else None,
|
||||
"policy_denials": policy_denials,
|
||||
},
|
||||
)
|
||||
|
||||
def compile_package(self, selection: dict[str, Any], *, source_ref: str = "selection") -> dict[str, Any]:
|
||||
request = self.package_request(selection)
|
||||
response = self.package_compiler.compile_selection(selection)
|
||||
return self._envelope(
|
||||
"package.compile",
|
||||
subject_kind="memory_selection",
|
||||
subject_id=str(selection.get("id") or ""),
|
||||
valid=True,
|
||||
diagnostics=(),
|
||||
source_ref=source_ref,
|
||||
data={"package_request": request, "package_response": response},
|
||||
)
|
||||
|
||||
def export_graph(self, *, graph_id: str = "local", source_ref: str = "local-store") -> dict[str, Any]:
|
||||
events = self.event_log.list_events()
|
||||
if hasattr(self.graph_store, "export_graph"):
|
||||
graph = self.graph_store.export_graph(graph_id=graph_id, events=events)
|
||||
else:
|
||||
graph = MemoryGraph(graph_id=graph_id, nodes=tuple(self.graph_store.list_nodes()), events=tuple(events))
|
||||
return self._envelope(
|
||||
"graph.export",
|
||||
subject_kind="memory_graph",
|
||||
subject_id=graph.graph_id,
|
||||
valid=True,
|
||||
diagnostics=(),
|
||||
source_ref=source_ref,
|
||||
data={"graph": graph.to_dict()},
|
||||
)
|
||||
|
||||
def repair_diagnostics(self, *, source_ref: str = "local-store") -> dict[str, Any]:
|
||||
event_diagnostics = self.event_log.diagnostics() if hasattr(self.event_log, "diagnostics") else ()
|
||||
events = self.event_log.list_events()
|
||||
store_diagnostics = (
|
||||
self.graph_store.repair_diagnostics(events=events)
|
||||
if hasattr(self.graph_store, "repair_diagnostics")
|
||||
else ()
|
||||
)
|
||||
diagnostics = tuple(event_diagnostics) + tuple(store_diagnostics)
|
||||
return self._envelope(
|
||||
"store.repair.diagnostics",
|
||||
subject_kind="local_store",
|
||||
subject_id=source_ref,
|
||||
valid=not any(diagnostic.severity == "error" for diagnostic in diagnostics),
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={"diagnostic_count": len(diagnostics)},
|
||||
)
|
||||
|
||||
def apply_lifecycle_actions(
|
||||
self,
|
||||
actions: Iterable[LifecycleAction | dict[str, Any]],
|
||||
*,
|
||||
approval_marker: str = "",
|
||||
review_record: ReviewRecord | dict[str, Any] | None = None,
|
||||
source_ref: str = "lifecycle-actions",
|
||||
) -> dict[str, Any]:
|
||||
applied: list[dict[str, Any]] = []
|
||||
denied: list[dict[str, Any]] = []
|
||||
diagnostics: list[Diagnostic] = []
|
||||
|
||||
for raw_action in actions:
|
||||
action = _coerce_action(raw_action)
|
||||
review = _coerce_review(review_record, action=action, approval_marker=approval_marker)
|
||||
if action.requires_review and (
|
||||
review is None or not review.approved or review.reviewed_action_id != _action_review_id(action)
|
||||
):
|
||||
denied.append(
|
||||
{
|
||||
"target_id": action.target_id,
|
||||
"action": action.action.value,
|
||||
"reason": "review_required",
|
||||
}
|
||||
)
|
||||
diagnostics.append(
|
||||
Diagnostic(
|
||||
"warn",
|
||||
"review_required",
|
||||
"Lifecycle action requires an approval marker before apply.",
|
||||
action.target_id,
|
||||
{"action": action.action.value},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
applied.append(self._apply_action(action, approval_marker=approval_marker or (review.review_id if review else "")))
|
||||
|
||||
return self._envelope(
|
||||
"lifecycle.apply",
|
||||
subject_kind="lifecycle_actions",
|
||||
subject_id=f"batch:{stable_digest([applied, denied])}",
|
||||
valid=not denied,
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
dry_run=False,
|
||||
data={
|
||||
"applied": applied,
|
||||
"denied": denied,
|
||||
"approval_marker": approval_marker,
|
||||
"review_record": review_record.to_dict() if isinstance(review_record, ReviewRecord) else review_record,
|
||||
},
|
||||
)
|
||||
|
||||
def package_request(self, selection: dict[str, Any]) -> dict[str, Any]:
|
||||
request_id = f"package-request:{stable_digest(selection)}"
|
||||
return {
|
||||
"schema_version": PACKAGE_REQUEST_SCHEMA,
|
||||
"id": request_id,
|
||||
"selection": dict(selection),
|
||||
"compiler": self.package_compiler.__class__.__name__,
|
||||
"dry_run": True,
|
||||
}
|
||||
|
||||
def _contract_envelope(
|
||||
self,
|
||||
operation: str,
|
||||
result: ContractIngressResult,
|
||||
*,
|
||||
source_ref: str,
|
||||
) -> dict[str, Any]:
|
||||
return self._envelope(
|
||||
operation,
|
||||
subject_kind=result.subject_kind,
|
||||
subject_id=result.subject_id,
|
||||
valid=result.valid,
|
||||
diagnostics=result.diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={"value": result.value.to_dict() if hasattr(result.value, "to_dict") else result.value},
|
||||
)
|
||||
|
||||
def _envelope(
|
||||
self,
|
||||
operation: str,
|
||||
*,
|
||||
subject_kind: str,
|
||||
subject_id: str,
|
||||
valid: bool,
|
||||
diagnostics: Iterable[Diagnostic],
|
||||
source_ref: str,
|
||||
data: dict[str, Any],
|
||||
dry_run: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
policy = self.policy_gateway.authorize(
|
||||
action=operation,
|
||||
resource=f"{subject_kind}:{subject_id}",
|
||||
context={"source_ref": source_ref, "dry_run": dry_run},
|
||||
)
|
||||
operation_id = f"op:{stable_digest([operation, subject_kind, subject_id, source_ref, data])}"
|
||||
audit = self.audit_sink.record(
|
||||
audit_event(
|
||||
operation_id=operation_id,
|
||||
operation=operation,
|
||||
subject={"kind": subject_kind, "id": subject_id},
|
||||
policy_decision=policy,
|
||||
dry_run=dry_run,
|
||||
source_ref=source_ref,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"schema_version": RUNTIME_ENVELOPE_SCHEMA,
|
||||
"operation_id": operation_id,
|
||||
"operation": operation,
|
||||
"dry_run": dry_run,
|
||||
"valid": valid and policy.allowed,
|
||||
"subject": {"kind": subject_kind, "id": subject_id},
|
||||
"source": {"ref": source_ref},
|
||||
"policy_decision": _policy_to_dict(policy),
|
||||
"audit_receipt": audit,
|
||||
"diagnostics": [diagnostic.to_dict() for diagnostic in diagnostics],
|
||||
"data": data,
|
||||
}
|
||||
|
||||
def _apply_action(self, action: LifecycleAction, *, approval_marker: str) -> dict[str, Any]:
|
||||
if action.action == LifecycleActionKind.COMPACT:
|
||||
summary = MemoryNode(
|
||||
action.target_id,
|
||||
"summary",
|
||||
str(action.metadata.get("summary_text") or "Compacted memory summary."),
|
||||
phase=MemoryPhase.STABILIZED,
|
||||
metadata={
|
||||
"source_node_ids": list(action.metadata.get("source_node_ids", ())),
|
||||
"approval_marker": approval_marker,
|
||||
},
|
||||
)
|
||||
self.graph_store.save_node(summary)
|
||||
return {"target_id": action.target_id, "action": action.action.value, "applied": True}
|
||||
|
||||
if action.to_state is not None:
|
||||
try:
|
||||
node = self.graph_store.get_node(action.target_id)
|
||||
except (KeyError, FileNotFoundError):
|
||||
return {"target_id": action.target_id, "action": action.action.value, "applied": False, "reason": "missing_node"}
|
||||
self.graph_store.save_node(
|
||||
replace(
|
||||
node,
|
||||
lifecycle=action.to_state,
|
||||
metadata={**dict(node.metadata), "last_lifecycle_action": action.action.value, "approval_marker": approval_marker},
|
||||
)
|
||||
)
|
||||
return {"target_id": action.target_id, "action": action.action.value, "applied": True}
|
||||
|
||||
return {"target_id": action.target_id, "action": action.action.value, "applied": False, "reason": "no_state_change"}
|
||||
|
||||
|
||||
def _policy_to_dict(decision: PolicyDecision) -> dict[str, Any]:
|
||||
return decision.to_dict() if hasattr(decision, "to_dict") else to_plain(decision)
|
||||
|
||||
|
||||
def _coerce_action(data: LifecycleAction | dict[str, Any]) -> LifecycleAction:
|
||||
if isinstance(data, LifecycleAction):
|
||||
return data
|
||||
return LifecycleAction(
|
||||
action=LifecycleActionKind(str(data["action"])),
|
||||
target_id=str(data["target_id"]),
|
||||
from_state=LifecycleState(str(data["from_state"])) if data.get("from_state") else None,
|
||||
to_state=LifecycleState(str(data["to_state"])) if data.get("to_state") else None,
|
||||
reason=str(data.get("reason") or ""),
|
||||
requires_review=bool(data.get("requires_review")),
|
||||
metadata=dict(data.get("metadata") or {}),
|
||||
)
|
||||
|
||||
|
||||
def _action_review_id(action: LifecycleAction) -> str:
|
||||
return f"action:{stable_digest(action.to_dict())}"
|
||||
|
||||
|
||||
def _coerce_review(
|
||||
data: ReviewRecord | dict[str, Any] | None,
|
||||
*,
|
||||
action: LifecycleAction,
|
||||
approval_marker: str,
|
||||
) -> ReviewRecord | None:
|
||||
if isinstance(data, ReviewRecord):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
return ReviewRecord.from_mapping(data)
|
||||
if approval_marker:
|
||||
return make_review_record(
|
||||
reviewed_action_id=_action_review_id(action),
|
||||
reviewer="local",
|
||||
approved=True,
|
||||
reason=approval_marker,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _policy_filtered_graph(
|
||||
graph: MemoryGraph,
|
||||
policy_context: dict[str, Any],
|
||||
) -> tuple[MemoryGraph, list[dict[str, Any]], list[Diagnostic]]:
|
||||
allowed_nodes = []
|
||||
denials: list[dict[str, Any]] = []
|
||||
diagnostics: list[Diagnostic] = []
|
||||
for node in graph.nodes:
|
||||
allowed, reasons = evaluate_activation_node(
|
||||
node,
|
||||
required_labels=tuple(policy_context.get("required_labels", ())),
|
||||
denied_labels=tuple(policy_context.get("denied_labels", ())),
|
||||
trust_zone=str(policy_context.get("trust_zone") or ""),
|
||||
secrets_allowed=bool(policy_context.get("secrets_allowed", True)),
|
||||
approved_reauthorizations=tuple(policy_context.get("approved_reauthorizations", ())),
|
||||
require_fresh=bool(policy_context.get("require_fresh", False)),
|
||||
)
|
||||
if allowed:
|
||||
allowed_nodes.append(node)
|
||||
continue
|
||||
denials.append(redacted_node(node, reasons=reasons))
|
||||
diagnostics.append(policy_denial_diagnostic(node, reasons))
|
||||
|
||||
allowed_ids = {node.node_id for node in allowed_nodes}
|
||||
filtered_edges = tuple(edge for edge in graph.edges if edge.source in allowed_ids and edge.target in allowed_ids)
|
||||
return replace(graph, nodes=tuple(allowed_nodes), edges=filtered_edges), denials, diagnostics
|
||||
Reference in New Issue
Block a user