131 lines
4.6 KiB
Python
131 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Detect and publish the Kanban API boundary required by CLI lanes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import sys
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from cli_lane_config import STATE_ROOT, utc_now
|
|
from cli_lane_files import atomic_json, load_json
|
|
|
|
|
|
_CAPABILITIES: KanbanCapabilities | None = None
|
|
_CAPABILITY_SOURCE: tuple[int, int] | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class KanbanCapabilities:
|
|
"""Safety-relevant keyword support exposed by the image's Kanban DB."""
|
|
|
|
exact_run_completion: bool
|
|
ended_run_replay: bool
|
|
exact_run_reclaim: bool
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Return whether dispatch and every recovery transition are safe."""
|
|
return (
|
|
self.exact_run_completion
|
|
and self.ended_run_replay
|
|
and self.exact_run_reclaim
|
|
)
|
|
|
|
@property
|
|
def deferred_features(self) -> tuple[str, ...]:
|
|
"""List fixed, bounded capability names absent from the image API."""
|
|
missing = []
|
|
if not self.exact_run_completion:
|
|
missing.append("exact-run-completion")
|
|
if not self.ended_run_replay:
|
|
missing.append("ended-run-replay")
|
|
if not self.exact_run_reclaim:
|
|
missing.append("exact-run-reclaim")
|
|
return tuple(missing)
|
|
|
|
|
|
def _explicit_keyword(function: Callable[..., Any], keyword: str) -> bool:
|
|
"""Require an explicit keyword parameter rather than trusting ``**kwargs``."""
|
|
try:
|
|
parameter = inspect.signature(function).parameters.get(keyword)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return parameter is not None and parameter.kind in {
|
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
inspect.Parameter.KEYWORD_ONLY,
|
|
}
|
|
|
|
|
|
def detect_kanban_capabilities(kanban_db: Any) -> KanbanCapabilities:
|
|
"""Inspect the loaded image API without invoking a mutating DB operation."""
|
|
complete_task = getattr(kanban_db, "complete_task", None)
|
|
reclaim_task = getattr(kanban_db, "reclaim_task", None)
|
|
return KanbanCapabilities(
|
|
exact_run_completion=callable(complete_task)
|
|
and _explicit_keyword(complete_task, "expected_run_id"),
|
|
ended_run_replay=callable(complete_task)
|
|
and _explicit_keyword(complete_task, "replay_ended_run_id"),
|
|
exact_run_reclaim=callable(reclaim_task)
|
|
and _explicit_keyword(reclaim_task, "expected_run_id"),
|
|
)
|
|
|
|
|
|
def _source_identity(kanban_db: Any) -> tuple[int, int]:
|
|
"""Bind cached capability evidence to the two inspected callables."""
|
|
return (
|
|
id(getattr(kanban_db, "complete_task", None)),
|
|
id(getattr(kanban_db, "reclaim_task", None)),
|
|
)
|
|
|
|
|
|
def _health_document(capabilities: KanbanCapabilities) -> dict[str, Any]:
|
|
"""Return a bounded operator-readable compatibility health document."""
|
|
return {
|
|
"component": "cli-lane-runner",
|
|
"state": "ready" if capabilities.ready else "deferred",
|
|
"ready": capabilities.ready,
|
|
"active_run_completion_safe": capabilities.exact_run_completion,
|
|
"deferred_features": list(capabilities.deferred_features),
|
|
"capabilities": asdict(capabilities),
|
|
"observed_at": utc_now(),
|
|
}
|
|
|
|
|
|
def initialize_kanban_capabilities(
|
|
kanban_db: Any,
|
|
*,
|
|
health_path: Path | None = None,
|
|
) -> KanbanCapabilities:
|
|
"""Detect startup compatibility and durably expose ready/deferred health."""
|
|
global _CAPABILITIES, _CAPABILITY_SOURCE
|
|
capabilities = detect_kanban_capabilities(kanban_db)
|
|
_CAPABILITIES = capabilities
|
|
_CAPABILITY_SOURCE = _source_identity(kanban_db)
|
|
destination = health_path or STATE_ROOT / "runtime-health.json"
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
atomic_json(destination, _health_document(capabilities), 0o600)
|
|
if not capabilities.ready:
|
|
print(
|
|
"CLI lane safety compatibility deferred; missing image APIs: "
|
|
+ ", ".join(capabilities.deferred_features),
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
return capabilities
|
|
|
|
|
|
def kanban_capabilities(kanban_db: Any) -> KanbanCapabilities:
|
|
"""Return startup evidence, redetecting only when the loaded API changes."""
|
|
if _CAPABILITIES is None or _source_identity(kanban_db) != _CAPABILITY_SOURCE:
|
|
return initialize_kanban_capabilities(kanban_db)
|
|
return _CAPABILITIES
|
|
|
|
|
|
def runtime_health(path: Path | None = None) -> dict[str, Any]:
|
|
"""Read the bounded health document for probes and operator diagnostics."""
|
|
document = load_json(path or STATE_ROOT / "runtime-health.json")
|
|
return document if isinstance(document, dict) else {}
|