generated from coulomb/repo-seed
Implement live-shaped readiness workplan
This commit is contained in:
@@ -10,6 +10,7 @@ from .bridge import (
|
||||
package_response_envelope,
|
||||
)
|
||||
from .contracts import graph_from_markitect, profile_from_markitect
|
||||
from .evaluation import EVALUATION_REPORT_SCHEMA, evaluation_threshold_report
|
||||
from .external_adapters import (
|
||||
ADAPTER_PACK_MANIFEST_SCHEMA,
|
||||
ExternalAdapterPack,
|
||||
@@ -20,9 +21,17 @@ from .external_adapters import (
|
||||
FakeKontextualRuntimeRegistry,
|
||||
FakeMarkitectPackageCompiler,
|
||||
FakeTelemetryAuditSink,
|
||||
LiveShapedKontextualEventLog,
|
||||
LiveShapedKontextualGraphStore,
|
||||
LiveShapedKontextualRuntimeRegistry,
|
||||
LiveShapedMarkitectPackageCompiler,
|
||||
LiveShapedPermissionSemanticIndex,
|
||||
LiveShapedPolicyGateway,
|
||||
LiveShapedTelemetryAuditSink,
|
||||
adapter_pack_manifest,
|
||||
fake_external_adapter_pack,
|
||||
fake_external_runtime_config,
|
||||
live_shaped_adapter_pack,
|
||||
validate_adapter_pack_manifest,
|
||||
)
|
||||
from .lifecycle import (
|
||||
@@ -67,6 +76,7 @@ from .retrieval import (
|
||||
select_event_path,
|
||||
)
|
||||
from .service import LocalServiceRunner, RuntimeAdapterBundle, RuntimeConfig, health_report, resolve_runtime_adapters, runtime_from_config, service_contracts
|
||||
from .service_binding import READINESS_REPORT_SCHEMA, SERVICE_BINDING_SCHEMA, ServiceBinding, ServiceResponse, service_binding_from_config
|
||||
from .planner import plan_profile_execution
|
||||
from .runtime import PhaseMemoryRuntime
|
||||
|
||||
@@ -75,6 +85,7 @@ __all__ = [
|
||||
"ADAPTER_PACK_MANIFEST_SCHEMA",
|
||||
"Diagnostic",
|
||||
"ExternalAdapterPack",
|
||||
"EVALUATION_REPORT_SCHEMA",
|
||||
"FakeExternalEventLog",
|
||||
"FakeExternalGraphStore",
|
||||
"FakeExternalPolicyGateway",
|
||||
@@ -82,6 +93,13 @@ __all__ = [
|
||||
"FakeKontextualRuntimeRegistry",
|
||||
"FakeMarkitectPackageCompiler",
|
||||
"FakeTelemetryAuditSink",
|
||||
"LiveShapedKontextualEventLog",
|
||||
"LiveShapedKontextualGraphStore",
|
||||
"LiveShapedKontextualRuntimeRegistry",
|
||||
"LiveShapedMarkitectPackageCompiler",
|
||||
"LiveShapedPermissionSemanticIndex",
|
||||
"LiveShapedPolicyGateway",
|
||||
"LiveShapedTelemetryAuditSink",
|
||||
"LifecycleAction",
|
||||
"LifecycleActionKind",
|
||||
"LifecycleState",
|
||||
@@ -113,6 +131,7 @@ __all__ = [
|
||||
"compact_path",
|
||||
"create_path",
|
||||
"graph_from_markitect",
|
||||
"evaluation_threshold_report",
|
||||
"merge_path",
|
||||
"make_review_record",
|
||||
"plan_activation",
|
||||
@@ -127,6 +146,7 @@ __all__ = [
|
||||
"profile_from_markitect",
|
||||
"fake_external_adapter_pack",
|
||||
"fake_external_runtime_config",
|
||||
"live_shaped_adapter_pack",
|
||||
"adapter_pack_manifest",
|
||||
"validate_adapter_pack_manifest",
|
||||
"path_event",
|
||||
@@ -140,9 +160,14 @@ __all__ = [
|
||||
"RuntimeConfig",
|
||||
"LocalServiceRunner",
|
||||
"RuntimeAdapterBundle",
|
||||
"READINESS_REPORT_SCHEMA",
|
||||
"SERVICE_BINDING_SCHEMA",
|
||||
"ServiceBinding",
|
||||
"ServiceResponse",
|
||||
"health_report",
|
||||
"resolve_runtime_adapters",
|
||||
"runtime_from_config",
|
||||
"service_binding_from_config",
|
||||
"service_contracts",
|
||||
]
|
||||
|
||||
|
||||
@@ -3,13 +3,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .models import Diagnostic, MemoryEdge, MemoryEvent, MemoryGraph, MemoryNode, MemoryPath, PolicyDecision, ProfileIntent
|
||||
from .utils import parse_iso_datetime, stable_digest, utc_now_iso
|
||||
|
||||
LOCAL_STORE_SCHEMA = "phase_memory.local_store.v1"
|
||||
LOCAL_STORE_METADATA_FILE = "phase-memory.json"
|
||||
LOCAL_STORE_MIGRATION_PLAN_SCHEMA = "phase_memory.local_store.migration_plan.v1"
|
||||
LOCAL_STORE_MIGRATION_RESULT_SCHEMA = "phase_memory.local_store.migration_result.v1"
|
||||
AUDIT_EXPORT_BATCH_SCHEMA = "phase_memory.audit.export_batch.v1"
|
||||
AUDIT_RETENTION_PLAN_SCHEMA = "phase_memory.audit.retention_plan.v1"
|
||||
|
||||
|
||||
class InMemoryMemoryGraphStore:
|
||||
@@ -145,6 +151,109 @@ class FileBackedMemoryGraphStore:
|
||||
def metadata(self) -> dict[str, Any]:
|
||||
return _read_json(self.root / LOCAL_STORE_METADATA_FILE)
|
||||
|
||||
def migration_plan(self) -> dict[str, Any]:
|
||||
metadata_path = self.root / LOCAL_STORE_METADATA_FILE
|
||||
diagnostics = list(self.metadata_diagnostics())
|
||||
metadata: dict[str, Any] = {}
|
||||
schema_version = ""
|
||||
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
metadata = _read_json(metadata_path)
|
||||
schema_version = str(metadata.get("schema_version") or "")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
actions: list[dict[str, Any]] = []
|
||||
if not any(diagnostic.code == "corrupt_store_metadata" for diagnostic in diagnostics):
|
||||
if schema_version != LOCAL_STORE_SCHEMA:
|
||||
actions.append(
|
||||
{
|
||||
"id": "set_schema_version",
|
||||
"action": "set_schema_version",
|
||||
"from_schema_version": schema_version,
|
||||
"to_schema_version": LOCAL_STORE_SCHEMA,
|
||||
}
|
||||
)
|
||||
planned = metadata.get("planned_migrations") or metadata.get("migrations") or ()
|
||||
for item in planned:
|
||||
migration_id = str(item)
|
||||
actions.append(
|
||||
{
|
||||
"id": f"complete_planned:{migration_id}",
|
||||
"action": "complete_planned_migration",
|
||||
"migration": migration_id,
|
||||
}
|
||||
)
|
||||
|
||||
plan_id = f"store-migration:{stable_digest([str(self.root), schema_version, actions])}"
|
||||
return {
|
||||
"schema_version": LOCAL_STORE_MIGRATION_PLAN_SCHEMA,
|
||||
"id": plan_id,
|
||||
"store_path": str(self.root),
|
||||
"metadata_path": str(metadata_path),
|
||||
"current_schema_version": schema_version,
|
||||
"target_schema_version": LOCAL_STORE_SCHEMA,
|
||||
"valid": not any(diagnostic.severity == "error" for diagnostic in diagnostics),
|
||||
"dry_run": True,
|
||||
"actions": actions,
|
||||
"diagnostics": [diagnostic.to_dict() for diagnostic in diagnostics],
|
||||
}
|
||||
|
||||
def apply_migration_plan(self, plan: dict[str, Any] | None = None, *, actor: str = "local") -> dict[str, Any]:
|
||||
plan = dict(plan or self.migration_plan())
|
||||
diagnostics = [dict(item) for item in plan.get("diagnostics", ())]
|
||||
errors = [item for item in diagnostics if item.get("severity") == "error"]
|
||||
if errors:
|
||||
return {
|
||||
"schema_version": LOCAL_STORE_MIGRATION_RESULT_SCHEMA,
|
||||
"plan_id": plan.get("id", ""),
|
||||
"store_path": str(self.root),
|
||||
"applied": False,
|
||||
"changed": False,
|
||||
"actions": [],
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
|
||||
metadata_path = self.root / LOCAL_STORE_METADATA_FILE
|
||||
try:
|
||||
metadata = _read_json(metadata_path) if metadata_path.exists() else {}
|
||||
except json.JSONDecodeError:
|
||||
metadata = {}
|
||||
|
||||
actions = [dict(item) for item in plan.get("actions", ())]
|
||||
completed = list(metadata.get("completed_migrations") or ())
|
||||
for action in actions:
|
||||
if action.get("action") == "set_schema_version":
|
||||
metadata["schema_version"] = LOCAL_STORE_SCHEMA
|
||||
if action.get("action") == "complete_planned_migration":
|
||||
completed.append(str(action.get("migration") or ""))
|
||||
|
||||
if actions:
|
||||
metadata["schema_version"] = LOCAL_STORE_SCHEMA
|
||||
metadata["migrations"] = []
|
||||
metadata.pop("planned_migrations", None)
|
||||
if completed:
|
||||
metadata["completed_migrations"] = sorted({item for item in completed if item})
|
||||
metadata["last_migration"] = {
|
||||
"plan_id": str(plan.get("id") or ""),
|
||||
"actor": actor,
|
||||
"applied_at": utc_now_iso(),
|
||||
"actions": [str(action.get("id") or "") for action in actions],
|
||||
}
|
||||
_write_json(metadata_path, metadata)
|
||||
|
||||
return {
|
||||
"schema_version": LOCAL_STORE_MIGRATION_RESULT_SCHEMA,
|
||||
"plan_id": plan.get("id", ""),
|
||||
"store_path": str(self.root),
|
||||
"applied": True,
|
||||
"changed": bool(actions),
|
||||
"actions": actions,
|
||||
"metadata": metadata,
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
|
||||
def repair_diagnostics(self, *, events: list[MemoryEvent] | None = None) -> tuple[Diagnostic, ...]:
|
||||
diagnostics: list[Diagnostic] = []
|
||||
nodes, node_diagnostics = _read_records(self.nodes_dir, MemoryNode.from_mapping, record_type="node")
|
||||
@@ -323,6 +432,13 @@ class RecordingAuditSink:
|
||||
def retention_metadata(self) -> dict[str, Any]:
|
||||
return {"mode": "in_memory", "retention_days": None}
|
||||
|
||||
def retention_plan(self, *, retention_days: int | None = None, now: datetime | None = None) -> dict[str, Any]:
|
||||
return audit_retention_plan(self.events, retention_days=retention_days, now=now, retention=self.retention_metadata())
|
||||
|
||||
def export_batch(self, **filters: Any) -> dict[str, Any]:
|
||||
events = self.query(**filters)
|
||||
return audit_export_batch(events, filters=filters, retention=self.retention_metadata())
|
||||
|
||||
|
||||
class JsonlAuditSink:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
@@ -352,6 +468,13 @@ class JsonlAuditSink:
|
||||
def retention_metadata(self) -> dict[str, Any]:
|
||||
return {"mode": "jsonl", "path": str(self.path), "retention_days": None}
|
||||
|
||||
def retention_plan(self, *, retention_days: int | None = None, now: datetime | None = None) -> dict[str, Any]:
|
||||
return audit_retention_plan(self.query(), retention_days=retention_days, now=now, retention=self.retention_metadata())
|
||||
|
||||
def export_batch(self, **filters: Any) -> dict[str, Any]:
|
||||
events = self.query(**filters)
|
||||
return audit_export_batch(events, filters=filters, retention=self.retention_metadata())
|
||||
|
||||
|
||||
class InMemorySemanticIndex:
|
||||
def __init__(self) -> None:
|
||||
@@ -429,6 +552,54 @@ def filter_audit_events(events: list[dict[str, Any]], **filters: Any) -> list[di
|
||||
return [dict(event) for event in events if _audit_event_matches(event, filters)]
|
||||
|
||||
|
||||
def audit_export_batch(
|
||||
events: list[dict[str, Any]],
|
||||
*,
|
||||
filters: dict[str, Any] | None = None,
|
||||
retention: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": AUDIT_EXPORT_BATCH_SCHEMA,
|
||||
"id": f"audit-export:{stable_digest([filters or {}, events])}",
|
||||
"filters": dict(filters or {}),
|
||||
"count": len(events),
|
||||
"events": [dict(event) for event in events],
|
||||
"retention": dict(retention or {}),
|
||||
}
|
||||
|
||||
|
||||
def audit_retention_plan(
|
||||
events: list[dict[str, Any]],
|
||||
*,
|
||||
retention_days: int | None = None,
|
||||
now: datetime | None = None,
|
||||
retention: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
retention = dict(retention or {})
|
||||
if retention_days is None:
|
||||
retention_days = retention.get("retention_days")
|
||||
now = now or datetime.now(timezone.utc)
|
||||
eligible: list[str] = []
|
||||
retained: list[str] = []
|
||||
for event in events:
|
||||
event_id = str(event.get("operation_id") or event.get("id") or stable_digest(event))
|
||||
age = _event_age_days(event, now=now)
|
||||
if retention_days is not None and age is not None and age >= int(retention_days):
|
||||
eligible.append(event_id)
|
||||
else:
|
||||
retained.append(event_id)
|
||||
return {
|
||||
"schema_version": AUDIT_RETENTION_PLAN_SCHEMA,
|
||||
"id": f"audit-retention:{stable_digest([retention_days, eligible, retained])}",
|
||||
"retention_days": retention_days,
|
||||
"eligible_count": len(eligible),
|
||||
"retained_count": len(retained),
|
||||
"eligible_operation_ids": eligible,
|
||||
"retained_operation_ids": retained,
|
||||
"retention": retention,
|
||||
}
|
||||
|
||||
|
||||
def _audit_event_matches(event: dict[str, Any], filters: dict[str, Any]) -> bool:
|
||||
operation = filters.get("operation")
|
||||
if operation is not None and event.get("operation") != operation:
|
||||
@@ -455,3 +626,10 @@ def _audit_event_matches(event: dict[str, Any], filters: dict[str, Any]) -> bool
|
||||
if allowed is not None and bool(event.get("allowed")) is not bool(allowed):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _event_age_days(event: dict[str, Any], *, now: datetime) -> int | None:
|
||||
timestamp = parse_iso_datetime(str(event.get("timestamp") or ""))
|
||||
if timestamp is None:
|
||||
return None
|
||||
return max((now - timestamp).days, 0)
|
||||
|
||||
161
src/phase_memory/evaluation.py
Normal file
161
src/phase_memory/evaluation.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Evaluation threshold reports for deterministic scenario fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .adapters import InMemorySemanticIndex
|
||||
from .contracts import graph_from_markitect
|
||||
from .models import Diagnostic, MemoryPath
|
||||
from .retrieval import activation_quality_report, select_event_path
|
||||
from .runtime import PhaseMemoryRuntime
|
||||
|
||||
EVALUATION_REPORT_SCHEMA = "phase_memory.evaluation.threshold_report.v1"
|
||||
|
||||
DEFAULT_THRESHOLDS = {
|
||||
"policy_denial_count": 1,
|
||||
"lifecycle_action_count": 3,
|
||||
"path_event_count": 1,
|
||||
"semantic_hit_count": 1,
|
||||
"budget_omission_count": 1,
|
||||
"source_span_coverage": 1.0,
|
||||
"explanation_coverage": 1.0,
|
||||
}
|
||||
|
||||
|
||||
def evaluation_threshold_report(data: dict[str, Any], *, thresholds: dict[str, float] | None = None) -> dict[str, Any]:
|
||||
thresholds = {**DEFAULT_THRESHOLDS, **dict(thresholds or {})}
|
||||
scenarios = list(data.get("scenarios") or ())
|
||||
metrics = {
|
||||
"scenario_count": len(scenarios),
|
||||
"policy_denial_count": 0,
|
||||
"lifecycle_action_count": 0,
|
||||
"path_event_count": 0,
|
||||
"semantic_hit_count": 0,
|
||||
"budget_omission_count": 0,
|
||||
"source_span_coverage": 0.0,
|
||||
"explanation_coverage": 0.0,
|
||||
}
|
||||
scenario_reports: list[dict[str, Any]] = []
|
||||
|
||||
for scenario in scenarios:
|
||||
scenario_id = str(scenario.get("id") or "")
|
||||
if scenario_id == "policy-denied-activation":
|
||||
report = _policy_scenario(scenario)
|
||||
elif scenario_id == "profile-lifecycle-rules":
|
||||
report = _lifecycle_scenario(scenario)
|
||||
elif scenario_id == "budget-path-and-semantic-hints":
|
||||
report = _budget_scenario(scenario)
|
||||
else:
|
||||
report = {"id": scenario_id, "metrics": {}, "diagnostics": [{"severity": "warn", "code": "unknown_scenario", "message": "Scenario is not recognized by this report."}]}
|
||||
scenario_reports.append(report)
|
||||
for key, value in report.get("metrics", {}).items():
|
||||
if key in metrics and isinstance(value, (int, float)):
|
||||
metrics[key] += value
|
||||
|
||||
diagnostics = _threshold_diagnostics(metrics, thresholds)
|
||||
return {
|
||||
"schema_version": EVALUATION_REPORT_SCHEMA,
|
||||
"valid": not diagnostics,
|
||||
"metrics": metrics,
|
||||
"thresholds": thresholds,
|
||||
"scenarios": scenario_reports,
|
||||
"diagnostics": [diagnostic.to_dict() for diagnostic in diagnostics],
|
||||
}
|
||||
|
||||
|
||||
def _policy_scenario(scenario: dict[str, Any]) -> dict[str, Any]:
|
||||
runtime = PhaseMemoryRuntime()
|
||||
response = runtime.plan_activation(
|
||||
scenario["graph"],
|
||||
max_items=int(scenario["profile"].get("activation", {}).get("max_items") or 4),
|
||||
max_tokens=int(scenario["profile"].get("activation", {}).get("max_tokens") or 60),
|
||||
profile_id=scenario["profile"]["id"],
|
||||
policy_context={"denied_labels": ["restricted"], "secrets_allowed": False, "trust_zone": "local"},
|
||||
)
|
||||
denials = response["data"]["policy_denials"]
|
||||
return {
|
||||
"id": scenario["id"],
|
||||
"metrics": {"policy_denial_count": len(denials)},
|
||||
"diagnostics": response["diagnostics"],
|
||||
}
|
||||
|
||||
|
||||
def _lifecycle_scenario(scenario: dict[str, Any]) -> dict[str, Any]:
|
||||
runtime = PhaseMemoryRuntime()
|
||||
response = runtime.plan_lifecycle_with_profile(
|
||||
scenario["profile"],
|
||||
scenario["graph"],
|
||||
refresh_digests={"life.decision": "decision-new"},
|
||||
now=datetime(2026, 5, 18, tzinfo=timezone.utc),
|
||||
)
|
||||
return {
|
||||
"id": scenario["id"],
|
||||
"metrics": {"lifecycle_action_count": len(response["data"]["dry_run_actions"])},
|
||||
"diagnostics": response["diagnostics"],
|
||||
}
|
||||
|
||||
|
||||
def _budget_scenario(scenario: dict[str, Any]) -> dict[str, Any]:
|
||||
runtime = PhaseMemoryRuntime()
|
||||
graph = graph_from_markitect(scenario["graph"]).value
|
||||
activation = runtime.plan_activation(
|
||||
scenario["graph"],
|
||||
max_items=int(scenario["profile"]["activation"]["max_items"]),
|
||||
max_tokens=int(scenario["profile"]["activation"]["max_tokens"]),
|
||||
profile_id=scenario["profile"]["id"],
|
||||
priority_node_ids=tuple(scenario["expect"]["selected_node_ids"]),
|
||||
)
|
||||
plan = activation["data"]["activation_plan"]
|
||||
quality = activation_quality_report(_activation_plan_from_response(activation), expected_node_ids=tuple(scenario["expect"]["selected_node_ids"]))
|
||||
path_events = select_event_path(graph.events, MemoryPath.from_mapping(scenario["path"]), max_events=2)
|
||||
index = InMemorySemanticIndex()
|
||||
index.upsert_nodes(list(graph.nodes))
|
||||
semantic_hits = index.query(graph_id=graph.graph_id, query="semantic restart", limit=2)
|
||||
return {
|
||||
"id": scenario["id"],
|
||||
"metrics": {
|
||||
"path_event_count": len(path_events),
|
||||
"semantic_hit_count": 1 if semantic_hits and semantic_hits[0]["id"] == scenario["expect"]["semantic_top_id"] else 0,
|
||||
"budget_omission_count": len(plan["omitted"]),
|
||||
"source_span_coverage": quality["source_span_coverage"],
|
||||
"explanation_coverage": quality["explanation_coverage"],
|
||||
},
|
||||
"diagnostics": activation["diagnostics"],
|
||||
}
|
||||
|
||||
|
||||
def _activation_plan_from_response(response: dict[str, Any]):
|
||||
from .models import ActivationPlan
|
||||
|
||||
data = response["data"]["activation_plan"]
|
||||
return ActivationPlan(
|
||||
plan_id=data["plan_id"],
|
||||
graph_id=data["graph_id"],
|
||||
selected_node_ids=tuple(data["selected_node_ids"]),
|
||||
selected_event_ids=tuple(data["selected_event_ids"]),
|
||||
omitted=tuple(data["omitted"]),
|
||||
token_estimate=data["token_estimate"],
|
||||
max_items=data["max_items"],
|
||||
max_tokens=data["max_tokens"],
|
||||
selection=response["data"]["package_request"]["selection"],
|
||||
diagnostics=(),
|
||||
)
|
||||
|
||||
|
||||
def _threshold_diagnostics(metrics: dict[str, Any], thresholds: dict[str, float]) -> tuple[Diagnostic, ...]:
|
||||
diagnostics: list[Diagnostic] = []
|
||||
for key, threshold in sorted(thresholds.items()):
|
||||
actual = float(metrics.get(key) or 0)
|
||||
if actual < float(threshold):
|
||||
diagnostics.append(
|
||||
Diagnostic(
|
||||
"error",
|
||||
"evaluation_threshold_failed",
|
||||
"Evaluation metric did not meet its threshold.",
|
||||
key,
|
||||
{"actual": actual, "threshold": threshold},
|
||||
)
|
||||
)
|
||||
return tuple(diagnostics)
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .adapters import InMemoryMemoryEventLog, InMemoryMemoryGraphStore, InMemorySemanticIndex, filter_audit_events
|
||||
from .adapters import InMemoryMemoryEventLog, InMemoryMemoryGraphStore, InMemorySemanticIndex, audit_export_batch, audit_retention_plan, filter_audit_events
|
||||
from .models import Diagnostic, PolicyDecision
|
||||
from .service import RuntimeConfig
|
||||
from .utils import stable_digest
|
||||
@@ -47,6 +47,7 @@ class ExternalAdapterPack:
|
||||
capabilities: tuple[str, ...] = ()
|
||||
ownership_boundaries: dict[str, str] = field(default_factory=dict)
|
||||
required_conformance: dict[str, str] = field(default_factory=dict)
|
||||
capability_requirements: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -56,6 +57,7 @@ class ExternalAdapterPack:
|
||||
"capabilities": list(self.capabilities),
|
||||
"ownership_boundaries": dict(self.ownership_boundaries),
|
||||
"required_conformance": dict(self.required_conformance),
|
||||
"capability_requirements": {key: list(value) for key, value in sorted(self.capability_requirements.items())},
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@@ -137,6 +139,13 @@ class FakeTelemetryAuditSink:
|
||||
"retention_days": self.retention_days,
|
||||
}
|
||||
|
||||
def retention_plan(self, *, retention_days: int | None = None, now=None) -> dict[str, Any]:
|
||||
return audit_retention_plan(self.events, retention_days=retention_days, now=now, retention=self.retention_metadata())
|
||||
|
||||
def export_batch(self, **filters: Any) -> dict[str, Any]:
|
||||
events = self.query(**filters)
|
||||
return audit_export_batch(events, filters=filters, retention=self.retention_metadata())
|
||||
|
||||
|
||||
class FakeKontextualRuntimeRegistry:
|
||||
def __init__(self) -> None:
|
||||
@@ -192,6 +201,99 @@ def fake_external_adapter_pack() -> ExternalAdapterPack:
|
||||
)
|
||||
|
||||
|
||||
class LiveShapedKontextualGraphStore(FakeExternalGraphStore):
|
||||
"""Live-shaped Kontextual graph store fixture with deterministic behavior."""
|
||||
|
||||
|
||||
class LiveShapedKontextualEventLog(FakeExternalEventLog):
|
||||
"""Live-shaped Kontextual event log fixture with deterministic behavior."""
|
||||
|
||||
|
||||
class LiveShapedPermissionSemanticIndex(FakeExternalSemanticIndex):
|
||||
"""Live-shaped retrieval fixture that keeps tests local and deterministic."""
|
||||
|
||||
|
||||
class LiveShapedMarkitectPackageCompiler(FakeMarkitectPackageCompiler):
|
||||
def compile_selection(self, selection: dict[str, Any]) -> dict[str, Any]:
|
||||
response = super().compile_selection(selection)
|
||||
package_ref = f"markitect-live-shaped:{stable_digest(selection)}"
|
||||
return {
|
||||
**response,
|
||||
"package_id": package_ref,
|
||||
"package_ref": package_ref,
|
||||
"compiler": self.__class__.__name__,
|
||||
"transport": "fixture",
|
||||
}
|
||||
|
||||
|
||||
class LiveShapedPolicyGateway(FakeExternalPolicyGateway):
|
||||
"""Live-shaped policy gateway fixture with explicit deny action support."""
|
||||
|
||||
|
||||
class LiveShapedTelemetryAuditSink(FakeTelemetryAuditSink):
|
||||
def retention_metadata(self) -> dict[str, Any]:
|
||||
return {
|
||||
"mode": "live_shaped_telemetry",
|
||||
"retention_days": self.retention_days,
|
||||
"transport": "fixture",
|
||||
}
|
||||
|
||||
|
||||
class LiveShapedKontextualRuntimeRegistry(FakeKontextualRuntimeRegistry):
|
||||
def publish_runtime_envelope(self, envelope: dict[str, Any]) -> dict[str, Any]:
|
||||
reference = f"kontextual-live-shaped:{stable_digest(envelope)}"
|
||||
stored = dict(envelope)
|
||||
self._envelopes[reference] = stored
|
||||
return {
|
||||
"published": True,
|
||||
"reference": reference,
|
||||
"adapter": self.__class__.__name__,
|
||||
"envelope": stored,
|
||||
}
|
||||
|
||||
|
||||
def live_shaped_adapter_pack() -> ExternalAdapterPack:
|
||||
capability_requirements = {
|
||||
"graph_store": ("kontextual.graph-store.live-shaped",),
|
||||
"event_log": ("kontextual.event-log.live-shaped",),
|
||||
"policy_gateway": ("policy.gateway.live-shaped",),
|
||||
"audit_sink": ("telemetry.audit.live-shaped",),
|
||||
"package_compiler": ("markitect.package.compile.live-shaped",),
|
||||
"semantic_index": ("semantic-index.live-shaped",),
|
||||
"runtime_registry": ("kontextual.runtime.registry.live-shaped",),
|
||||
}
|
||||
ownership_boundaries = {
|
||||
"graph_store": "kontextual owns durable graph records; live fixture preserves phase-memory graph semantics",
|
||||
"event_log": "kontextual owns event durability; live fixture preserves phase-memory event shape",
|
||||
"policy_gateway": "external policy owns decision enforcement; phase-memory owns operation context",
|
||||
"audit_sink": "external telemetry owns retention/export; phase-memory owns audit event schema",
|
||||
"package_compiler": "markitect owns package compilation; phase-memory owns activation selection",
|
||||
"semantic_index": "external retrieval owns index ranking; phase-memory owns policy-filtered activation",
|
||||
"runtime_registry": "kontextual owns registry transport; phase-memory owns runtime envelope contract",
|
||||
}
|
||||
return ExternalAdapterPack(
|
||||
name="live-shaped",
|
||||
adapters={
|
||||
"graph_store": LiveShapedKontextualGraphStore(),
|
||||
"event_log": LiveShapedKontextualEventLog(),
|
||||
"policy_gateway": LiveShapedPolicyGateway(),
|
||||
"audit_sink": LiveShapedTelemetryAuditSink(retention_days=90),
|
||||
"package_compiler": LiveShapedMarkitectPackageCompiler(),
|
||||
"semantic_index": LiveShapedPermissionSemanticIndex(),
|
||||
"runtime_registry": LiveShapedKontextualRuntimeRegistry(),
|
||||
},
|
||||
capabilities=tuple(sorted({capability for values in capability_requirements.values() for capability in values})),
|
||||
ownership_boundaries=ownership_boundaries,
|
||||
required_conformance=dict(ADAPTER_CONFORMANCE_HELPERS),
|
||||
capability_requirements=capability_requirements,
|
||||
metadata={
|
||||
"intended_for": "local live-shaped compatibility tests",
|
||||
"requires_credentials": False,
|
||||
"network_required": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def fake_external_runtime_config() -> RuntimeConfig:
|
||||
return RuntimeConfig(
|
||||
adapter_registry={
|
||||
@@ -213,6 +315,7 @@ def fake_external_runtime_config() -> RuntimeConfig:
|
||||
|
||||
|
||||
def adapter_pack_manifest(pack: ExternalAdapterPack) -> dict[str, Any]:
|
||||
capability_requirements = _capability_requirements(pack)
|
||||
return {
|
||||
"schema_version": ADAPTER_PACK_MANIFEST_SCHEMA,
|
||||
"name": pack.name,
|
||||
@@ -222,7 +325,7 @@ def adapter_pack_manifest(pack: ExternalAdapterPack) -> dict[str, Any]:
|
||||
key: {
|
||||
"class": pack.adapters[key].__class__.__name__,
|
||||
"ownership": pack.ownership_boundaries.get(key, ""),
|
||||
"required_capabilities": list(ADAPTER_REQUIRED_CAPABILITIES.get(key, ())),
|
||||
"required_capabilities": list(capability_requirements.get(key, ())),
|
||||
"required_conformance": pack.required_conformance.get(key, ADAPTER_CONFORMANCE_HELPERS.get(key, "")),
|
||||
}
|
||||
for key in sorted(pack.adapters)
|
||||
@@ -233,6 +336,7 @@ def adapter_pack_manifest(pack: ExternalAdapterPack) -> dict[str, Any]:
|
||||
def validate_adapter_pack_manifest(pack: ExternalAdapterPack) -> tuple[Diagnostic, ...]:
|
||||
diagnostics: list[Diagnostic] = []
|
||||
capabilities = set(pack.capabilities)
|
||||
capability_requirements = _capability_requirements(pack)
|
||||
for adapter in ADAPTER_PACK_REQUIRED_ADAPTERS:
|
||||
if adapter not in pack.adapters:
|
||||
diagnostics.append(
|
||||
@@ -265,7 +369,7 @@ def validate_adapter_pack_manifest(pack: ExternalAdapterPack) -> tuple[Diagnosti
|
||||
{"adapter": adapter},
|
||||
)
|
||||
)
|
||||
for capability in ADAPTER_REQUIRED_CAPABILITIES.get(adapter, ()):
|
||||
for capability in capability_requirements.get(adapter, ()):
|
||||
if capability not in capabilities:
|
||||
diagnostics.append(
|
||||
Diagnostic(
|
||||
@@ -277,3 +381,9 @@ def validate_adapter_pack_manifest(pack: ExternalAdapterPack) -> tuple[Diagnosti
|
||||
)
|
||||
)
|
||||
return tuple(diagnostics)
|
||||
|
||||
|
||||
def _capability_requirements(pack: ExternalAdapterPack) -> dict[str, tuple[str, ...]]:
|
||||
if pack.capability_requirements:
|
||||
return {key: tuple(value) for key, value in pack.capability_requirements.items()}
|
||||
return dict(ADAPTER_REQUIRED_CAPABILITIES)
|
||||
|
||||
@@ -22,6 +22,8 @@ class MemoryOperation(str, Enum):
|
||||
ACTIVATION_PLAN = "graph.activation.plan"
|
||||
PACKAGE_COMPILE = "package.compile"
|
||||
AUDIT_QUERY = "audit.query"
|
||||
AUDIT_EXPORT = "audit.export"
|
||||
AUDIT_RETENTION_PLAN = "audit.retention.plan"
|
||||
LIFECYCLE_APPLY = "lifecycle.apply"
|
||||
STABILIZATION = "memory.stabilize"
|
||||
COMPACTION = "memory.compact"
|
||||
@@ -30,6 +32,8 @@ class MemoryOperation(str, Enum):
|
||||
ARCHIVE = "memory.archive"
|
||||
GRAPH_EXPORT = "graph.export"
|
||||
STORE_REPAIR = "store.repair.diagnostics"
|
||||
STORE_MIGRATION_PLAN = "store.migration.plan"
|
||||
STORE_MIGRATION_APPLY = "store.migration.apply"
|
||||
|
||||
|
||||
POLICY_OPERATION_POINTS = tuple(operation.value for operation in MemoryOperation)
|
||||
|
||||
@@ -38,6 +38,8 @@ from .utils import compact_dict, stable_digest, to_plain
|
||||
|
||||
RUNTIME_ENVELOPE_SCHEMA = "phase_memory.runtime.envelope.v1"
|
||||
AUDIT_QUERY_SCHEMA = "phase_memory.audit.query.v1"
|
||||
AUDIT_EXPORT_SCHEMA = "phase_memory.audit.export.v1"
|
||||
AUDIT_RETENTION_SCHEMA = "phase_memory.audit.retention.v1"
|
||||
PACKAGE_REQUEST_SCHEMA = MARKITECT_PACKAGE_REQUEST_SCHEMA
|
||||
|
||||
|
||||
@@ -300,6 +302,103 @@ class PhaseMemoryRuntime:
|
||||
"source": {"ref": source_ref},
|
||||
}
|
||||
|
||||
def export_audit_events(self, filters: dict[str, Any] | None = None, *, source_ref: str = "audit") -> dict[str, Any]:
|
||||
filters = _audit_filters(filters or {})
|
||||
policy = self.policy_gateway.authorize(
|
||||
action="audit.export",
|
||||
resource="audit:events",
|
||||
context={"source_ref": source_ref, "dry_run": True, "filters": filters},
|
||||
)
|
||||
if policy.allowed and hasattr(self.audit_sink, "export_batch"):
|
||||
batch = self.audit_sink.export_batch(**filters)
|
||||
diagnostics: tuple[Diagnostic, ...] = ()
|
||||
elif policy.allowed:
|
||||
events, diagnostics = _query_audit_sink(self.audit_sink, filters)
|
||||
batch = {
|
||||
"schema_version": "phase_memory.audit.export_batch.v1",
|
||||
"filters": filters,
|
||||
"count": len(events),
|
||||
"events": events,
|
||||
"retention": _audit_retention_metadata(self.audit_sink),
|
||||
}
|
||||
else:
|
||||
diagnostics = ()
|
||||
batch = {"filters": filters, "count": 0, "events": [], "retention": _audit_retention_metadata(self.audit_sink)}
|
||||
operation_id = f"op:{stable_digest(['audit.export', source_ref, filters])}"
|
||||
audit = self.audit_sink.record(
|
||||
audit_event(
|
||||
operation_id=operation_id,
|
||||
operation="audit.export",
|
||||
subject={"kind": "audit_events", "id": stable_digest(filters)},
|
||||
policy_decision=policy,
|
||||
dry_run=True,
|
||||
source_ref=source_ref,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"schema_version": AUDIT_EXPORT_SCHEMA,
|
||||
"operation_id": operation_id,
|
||||
"operation": "audit.export",
|
||||
"dry_run": True,
|
||||
"valid": policy.allowed and not any(diagnostic.severity == "error" for diagnostic in diagnostics),
|
||||
"batch": batch,
|
||||
"policy_decision": _policy_to_dict(policy),
|
||||
"audit_receipt": audit,
|
||||
"diagnostics": [diagnostic.to_dict() for diagnostic in diagnostics],
|
||||
"source": {"ref": source_ref},
|
||||
}
|
||||
|
||||
def audit_retention_plan(
|
||||
self,
|
||||
*,
|
||||
retention_days: int | None = None,
|
||||
now: datetime | None = None,
|
||||
source_ref: str = "audit",
|
||||
) -> dict[str, Any]:
|
||||
policy = self.policy_gateway.authorize(
|
||||
action="audit.retention.plan",
|
||||
resource="audit:events",
|
||||
context={"source_ref": source_ref, "dry_run": True, "retention_days": retention_days},
|
||||
)
|
||||
diagnostics: tuple[Diagnostic, ...] = ()
|
||||
if policy.allowed and hasattr(self.audit_sink, "retention_plan"):
|
||||
plan = self.audit_sink.retention_plan(retention_days=retention_days, now=now)
|
||||
elif policy.allowed:
|
||||
plan = {}
|
||||
diagnostics = (
|
||||
Diagnostic(
|
||||
"error",
|
||||
"audit_retention_plan_unsupported",
|
||||
"Audit sink does not expose retention planning.",
|
||||
self.audit_sink.__class__.__name__,
|
||||
),
|
||||
)
|
||||
else:
|
||||
plan = {}
|
||||
operation_id = f"op:{stable_digest(['audit.retention.plan', source_ref, retention_days, plan])}"
|
||||
audit = self.audit_sink.record(
|
||||
audit_event(
|
||||
operation_id=operation_id,
|
||||
operation="audit.retention.plan",
|
||||
subject={"kind": "audit_events", "id": "retention"},
|
||||
policy_decision=policy,
|
||||
dry_run=True,
|
||||
source_ref=source_ref,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"schema_version": AUDIT_RETENTION_SCHEMA,
|
||||
"operation_id": operation_id,
|
||||
"operation": "audit.retention.plan",
|
||||
"dry_run": True,
|
||||
"valid": policy.allowed and not any(diagnostic.severity == "error" for diagnostic in diagnostics),
|
||||
"plan": plan,
|
||||
"policy_decision": _policy_to_dict(policy),
|
||||
"audit_receipt": audit,
|
||||
"diagnostics": [diagnostic.to_dict() for diagnostic in diagnostics],
|
||||
"source": {"ref": source_ref},
|
||||
}
|
||||
|
||||
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"):
|
||||
@@ -335,6 +434,63 @@ class PhaseMemoryRuntime:
|
||||
data={"diagnostic_count": len(diagnostics)},
|
||||
)
|
||||
|
||||
def plan_store_migration(self, *, source_ref: str = "local-store") -> dict[str, Any]:
|
||||
if hasattr(self.graph_store, "migration_plan"):
|
||||
plan = self.graph_store.migration_plan()
|
||||
diagnostics = _diagnostics_from_dicts(plan.get("diagnostics", ()))
|
||||
else:
|
||||
plan = {}
|
||||
diagnostics = (
|
||||
Diagnostic(
|
||||
"error",
|
||||
"store_migration_unsupported",
|
||||
"Graph store does not expose local migration planning.",
|
||||
self.graph_store.__class__.__name__,
|
||||
),
|
||||
)
|
||||
return self._envelope(
|
||||
"store.migration.plan",
|
||||
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={"migration_plan": plan},
|
||||
)
|
||||
|
||||
def apply_store_migration(
|
||||
self,
|
||||
migration_plan: dict[str, Any] | None = None,
|
||||
*,
|
||||
actor: str = "local",
|
||||
source_ref: str = "local-store",
|
||||
) -> dict[str, Any]:
|
||||
if hasattr(self.graph_store, "apply_migration_plan"):
|
||||
result = self.graph_store.apply_migration_plan(migration_plan, actor=actor)
|
||||
diagnostics = _diagnostics_from_dicts(result.get("diagnostics", ()))
|
||||
valid = bool(result.get("applied")) and not any(diagnostic.severity == "error" for diagnostic in diagnostics)
|
||||
else:
|
||||
result = {}
|
||||
diagnostics = (
|
||||
Diagnostic(
|
||||
"error",
|
||||
"store_migration_unsupported",
|
||||
"Graph store does not expose local migration apply.",
|
||||
self.graph_store.__class__.__name__,
|
||||
),
|
||||
)
|
||||
valid = False
|
||||
return self._envelope(
|
||||
"store.migration.apply",
|
||||
subject_kind="local_store",
|
||||
subject_id=source_ref,
|
||||
valid=valid,
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
dry_run=False,
|
||||
data={"migration_result": result},
|
||||
)
|
||||
|
||||
def apply_lifecycle_actions(
|
||||
self,
|
||||
actions: Iterable[LifecycleAction | dict[str, Any]],
|
||||
@@ -487,6 +643,21 @@ def _policy_to_dict(decision: PolicyDecision) -> dict[str, Any]:
|
||||
return decision.to_dict() if hasattr(decision, "to_dict") else to_plain(decision)
|
||||
|
||||
|
||||
def _diagnostics_from_dicts(items: Iterable[dict[str, Any]]) -> tuple[Diagnostic, ...]:
|
||||
diagnostics: list[Diagnostic] = []
|
||||
for item in items:
|
||||
diagnostics.append(
|
||||
Diagnostic(
|
||||
str(item.get("severity") or "info"),
|
||||
str(item.get("code") or "diagnostic"),
|
||||
str(item.get("message") or ""),
|
||||
str(item.get("path") or ""),
|
||||
dict(item.get("metadata") or {}),
|
||||
)
|
||||
)
|
||||
return tuple(diagnostics)
|
||||
|
||||
|
||||
def _audit_filters(filters: dict[str, Any]) -> dict[str, Any]:
|
||||
allowed_keys = {
|
||||
"operation",
|
||||
|
||||
@@ -335,6 +335,8 @@ def health_report(runtime: PhaseMemoryRuntime, *, config: RuntimeConfig | None =
|
||||
class LocalServiceRunner:
|
||||
"""Minimal optional service runner shape without web framework dependency."""
|
||||
|
||||
SUPPORTED_OPERATIONS = tuple(SERVICE_OPERATIONS)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runtime: PhaseMemoryRuntime | None = None,
|
||||
@@ -345,6 +347,10 @@ class LocalServiceRunner:
|
||||
self.config = config or RuntimeConfig.local_default()
|
||||
self.runtime = runtime or runtime_from_config(self.config, external_adapters=external_adapters)
|
||||
|
||||
@classmethod
|
||||
def supported_operations(cls) -> tuple[str, ...]:
|
||||
return cls.SUPPORTED_OPERATIONS
|
||||
|
||||
def handle(self, operation: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
if operation == "health.check":
|
||||
|
||||
164
src/phase_memory/service_binding.py
Normal file
164
src/phase_memory/service_binding.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Optional framework-neutral service binding for local embeddings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from io import BytesIO
|
||||
from typing import Any, Callable
|
||||
|
||||
from .service import HEALTH_REPORT_SCHEMA, LocalServiceRunner, RuntimeConfig, health_report, service_contracts
|
||||
|
||||
SERVICE_BINDING_SCHEMA = "phase_memory.service.binding.v1"
|
||||
READINESS_REPORT_SCHEMA = "phase_memory.service.readiness.v1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceResponse:
|
||||
status: int
|
||||
body: dict[str, Any]
|
||||
headers: dict[str, str] = field(default_factory=lambda: {"content-type": "application/json"})
|
||||
|
||||
def to_wsgi(self) -> tuple[str, list[tuple[str, str]], bytes]:
|
||||
status_text = _status_text(self.status)
|
||||
payload = json.dumps(self.body, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
headers = {**self.headers, "content-length": str(len(payload))}
|
||||
return status_text, list(headers.items()), payload
|
||||
|
||||
|
||||
class ServiceBinding:
|
||||
"""HTTP-shaped adapter around ``LocalServiceRunner`` without a server dependency."""
|
||||
|
||||
def __init__(self, runner: LocalServiceRunner | None = None) -> None:
|
||||
self.runner = runner or LocalServiceRunner()
|
||||
|
||||
def readiness(self) -> dict[str, Any]:
|
||||
health = health_report(self.runner.runtime, config=self.runner.config)
|
||||
contracts = service_contracts()
|
||||
supported = set(self.runner.supported_operations())
|
||||
declared = set(contracts["operations"])
|
||||
unsupported = sorted(declared - supported)
|
||||
extra = sorted(supported - declared)
|
||||
diagnostics = list(health["diagnostics"])
|
||||
if unsupported:
|
||||
diagnostics.append(
|
||||
{
|
||||
"severity": "error",
|
||||
"code": "unsupported_service_operations",
|
||||
"message": "Service binding cannot dispatch every declared operation.",
|
||||
"metadata": {"operations": unsupported},
|
||||
}
|
||||
)
|
||||
if extra:
|
||||
diagnostics.append(
|
||||
{
|
||||
"severity": "warn",
|
||||
"code": "undocumented_service_operations",
|
||||
"message": "Service binding exposes operations not declared in service contracts.",
|
||||
"metadata": {"operations": extra},
|
||||
}
|
||||
)
|
||||
ok = health["ok"] and not any(item.get("severity") == "error" for item in diagnostics)
|
||||
return {
|
||||
"schema_version": READINESS_REPORT_SCHEMA,
|
||||
"ok": ok,
|
||||
"health": health,
|
||||
"contracts": contracts,
|
||||
"supported_operations": sorted(supported),
|
||||
"unsupported_operations": unsupported,
|
||||
"extra_operations": extra,
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
|
||||
def route(self, method: str, path: str, body: dict[str, Any] | None = None) -> ServiceResponse:
|
||||
method = method.upper()
|
||||
path = _normalize_path(path)
|
||||
body = body or {}
|
||||
|
||||
if method == "GET" and path == "/health":
|
||||
health = health_report(self.runner.runtime, config=self.runner.config)
|
||||
return ServiceResponse(200 if health["ok"] else 503, health)
|
||||
if method == "GET" and path == "/ready":
|
||||
readiness = self.readiness()
|
||||
return ServiceResponse(200 if readiness["ok"] else 503, readiness)
|
||||
if method == "GET" and path == "/contracts":
|
||||
return ServiceResponse(200, service_contracts())
|
||||
if method == "POST" and path == "/operations":
|
||||
operation = str(body.get("operation") or "")
|
||||
return self._dispatch(operation, dict(body.get("payload") or {}))
|
||||
if method == "POST" and path.startswith("/operations/"):
|
||||
operation = path.removeprefix("/operations/").replace("/", ".")
|
||||
return self._dispatch(operation, body)
|
||||
return ServiceResponse(
|
||||
404,
|
||||
{
|
||||
"schema_version": SERVICE_BINDING_SCHEMA,
|
||||
"ok": False,
|
||||
"error": "not_found",
|
||||
"method": method,
|
||||
"path": path,
|
||||
},
|
||||
)
|
||||
|
||||
def as_wsgi_app(self) -> Callable:
|
||||
def app(environ: dict[str, Any], start_response: Callable) -> list[bytes]:
|
||||
method = str(environ.get("REQUEST_METHOD") or "GET")
|
||||
path = str(environ.get("PATH_INFO") or "/")
|
||||
body = _read_wsgi_json(environ)
|
||||
response = self.route(method, path, body)
|
||||
status, headers, payload = response.to_wsgi()
|
||||
start_response(status, headers)
|
||||
return [payload]
|
||||
|
||||
return app
|
||||
|
||||
def _dispatch(self, operation: str, payload: dict[str, Any]) -> ServiceResponse:
|
||||
if operation not in service_contracts()["operations"]:
|
||||
return ServiceResponse(
|
||||
404,
|
||||
{
|
||||
"schema_version": SERVICE_BINDING_SCHEMA,
|
||||
"ok": False,
|
||||
"error": "unsupported_operation",
|
||||
"operation": operation,
|
||||
},
|
||||
)
|
||||
try:
|
||||
return ServiceResponse(200, self.runner.handle(operation, payload))
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
return ServiceResponse(
|
||||
400,
|
||||
{
|
||||
"schema_version": SERVICE_BINDING_SCHEMA,
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"operation": operation,
|
||||
"message": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def service_binding_from_config(config: RuntimeConfig | None = None) -> ServiceBinding:
|
||||
return ServiceBinding(LocalServiceRunner(config=config or RuntimeConfig.local_default()))
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
normalized = "/" + path.strip("/")
|
||||
return normalized if normalized != "/" else "/"
|
||||
|
||||
|
||||
def _status_text(status: int) -> str:
|
||||
labels = {200: "OK", 400: "Bad Request", 404: "Not Found", 503: "Service Unavailable"}
|
||||
return f"{status} {labels.get(status, 'Unknown')}"
|
||||
|
||||
|
||||
def _read_wsgi_json(environ: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
length = int(environ.get("CONTENT_LENGTH") or 0)
|
||||
except ValueError:
|
||||
length = 0
|
||||
stream = environ.get("wsgi.input") or BytesIO()
|
||||
raw = stream.read(length) if length else b""
|
||||
if not raw:
|
||||
return {}
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
Reference in New Issue
Block a user