refactor(cloud): move package into workspace member
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
"""Cloud runtime: multi-host device pool, scheduler, plugins, and public SDK.
|
||||
|
||||
Sibling to ``agents/``, ``workflow/``, etc. Composes the existing single-process
|
||||
capabilities (``device-management``, ``agent-runtime``, ``workflow-orchestration``,
|
||||
``driver-registry``) without editing them.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"CloudConfig",
|
||||
"CloudStore",
|
||||
"DevicePool",
|
||||
"HostRegistration",
|
||||
"PooledDevice",
|
||||
"TaskScheduler",
|
||||
"ScheduledTask",
|
||||
"TaskConstraints",
|
||||
"AssignmentStrategy",
|
||||
"fifo_match_strategy",
|
||||
"TaskDispatcher",
|
||||
"Assignment",
|
||||
"RemoteDispatchNotSupportedError",
|
||||
"PluginManifest",
|
||||
"PluginRegistry",
|
||||
"DiscoveryResult",
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Configuration for the cloud runtime with conservative inert defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CloudConfig:
|
||||
"""Tunable cloud-runtime knobs.
|
||||
|
||||
Defaults are deliberately conservative so importing ``cloud`` is inert
|
||||
until a caller actually registers a second host or submits through the SDK.
|
||||
"""
|
||||
|
||||
# How frequently a host is expected to push its device snapshot. Informational
|
||||
# only at runtime (the pool does not run a timer); drives operator expectations.
|
||||
sync_interval_seconds: int = 30
|
||||
|
||||
# A host whose ``last_seen_at`` is older than this threshold reports all its
|
||||
# devices as ``unreachable`` on the next pool read.
|
||||
stale_after_seconds: int = 90
|
||||
|
||||
# Bound on the number of ``queued`` ScheduledTasks. Submissions beyond this
|
||||
# are rejected rather than letting the backlog grow without limit.
|
||||
max_queue_depth: int = 100
|
||||
|
||||
# Strategy name looked up in the AssignmentStrategy registry (default fifo_match).
|
||||
default_assignment_strategy: str = "fifo_match"
|
||||
|
||||
# Public SDK URL prefix. Mounted as the FastAPI APIRouter ``prefix``.
|
||||
api_version_prefix: str = "/v1"
|
||||
|
||||
# SQLite file path (relative to CWD or absolute) owned by CloudStore.
|
||||
db_path: str = "cloud/cloud.sqlite3"
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Task dispatcher: composes TaskRunner / WorkflowRunner to execute assignments.
|
||||
|
||||
Capability: ``task-scheduler`` (closing the loop from ``assigned`` to ``executed``).
|
||||
|
||||
Composes the existing single-process execution entry points (``runtime.task.TaskRunner``,
|
||||
``workflow.runner.WorkflowRunner``) without editing them, strictly through their
|
||||
public ``run(task) -> Task`` / ``run(definition, device_id) -> WorkflowRun`` contracts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from core.models import Task
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cloud.store import CloudStore
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Assignment:
|
||||
"""A scheduler-produced binding of a queued task to a specific device+host."""
|
||||
|
||||
task_id: str
|
||||
device_id: str
|
||||
host_id: str
|
||||
goal: str | None
|
||||
workflow_definition_id: str | None
|
||||
|
||||
|
||||
class RemoteDispatchNotSupportedError(RuntimeError):
|
||||
"""Raised when an assignment targets a host other than the local process."""
|
||||
|
||||
|
||||
class UnknownWorkflowDefinitionError(RuntimeError):
|
||||
"""Raised when a workflow-based assignment references an unknown definition id."""
|
||||
|
||||
|
||||
# Callable type aliases; kept lazy so importing this module never imports runtime/workflow.
|
||||
TaskRunnerFactory = Callable[[], "object"]
|
||||
WorkflowRunnerFactory = Callable[[], "object"]
|
||||
|
||||
|
||||
class TaskDispatcher:
|
||||
"""Executes assignments via the existing task / workflow runners.
|
||||
|
||||
Local-only in this change: an assignment whose ``host_id`` does not match
|
||||
the dispatcher's own ``local_host_id`` raises ``RemoteDispatchNotSupportedError``
|
||||
rather than attempting any execution over the network.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
local_host_id: str,
|
||||
task_runner_factory: TaskRunnerFactory,
|
||||
workflow_runner_factory: WorkflowRunnerFactory,
|
||||
store: "CloudStore",
|
||||
) -> None:
|
||||
self.local_host_id = local_host_id
|
||||
self.task_runner_factory = task_runner_factory
|
||||
self.workflow_runner_factory = workflow_runner_factory
|
||||
self.store = store
|
||||
|
||||
def dispatch(self, assignment: Assignment) -> None:
|
||||
if assignment.host_id != self.local_host_id:
|
||||
raise RemoteDispatchNotSupportedError(
|
||||
f"assignment {assignment.task_id} targets host "
|
||||
f"{assignment.host_id!r}, but this dispatcher owns {self.local_host_id!r}"
|
||||
)
|
||||
|
||||
if assignment.workflow_definition_id:
|
||||
status = self._dispatch_workflow(assignment)
|
||||
else:
|
||||
status = self._dispatch_goal(assignment)
|
||||
|
||||
self.store.update_task(assignment.task_id, status=status)
|
||||
|
||||
def _dispatch_goal(self, assignment: Assignment) -> str:
|
||||
task = Task(
|
||||
goal=assignment.goal or "",
|
||||
device_id=assignment.device_id,
|
||||
)
|
||||
result = self.task_runner_factory().run(task) # type: ignore[attr-defined]
|
||||
result_status = getattr(result, "status", None)
|
||||
return "done" if result_status == "completed" else "failed"
|
||||
|
||||
def _dispatch_workflow(self, assignment: Assignment) -> str:
|
||||
runner = self.workflow_runner_factory()
|
||||
store = getattr(runner, "store", None)
|
||||
if store is None:
|
||||
raise UnknownWorkflowDefinitionError(
|
||||
"workflow runner has no store to load definitions from"
|
||||
)
|
||||
definition = store.get_definition(assignment.workflow_definition_id)
|
||||
if definition is None:
|
||||
raise UnknownWorkflowDefinitionError(
|
||||
f"unknown workflow definition {assignment.workflow_definition_id!r}"
|
||||
)
|
||||
run = runner.run(definition, device_id=assignment.device_id) # type: ignore[attr-defined]
|
||||
run_status = getattr(run, "status", None)
|
||||
return "done" if run_status == "completed" else "failed"
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Plugin manifest and registry: discover and wire drivers/tools/skills.
|
||||
|
||||
Capability: ``plugin-system``.
|
||||
|
||||
Two discovery paths (Python ``importlib.metadata`` entry points in group
|
||||
``device_agent_runtime.plugins``, and a local ``plugin.json`` file scan) feed
|
||||
into one validation + registration path. Only ``driver``-kind plugins are
|
||||
concretely wired (into ``driver/registry.py``'s extension point); ``tool``- and
|
||||
``skill``-kind manifests are accepted and stored as ``wired=False``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cloud.store import CloudStore
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENTRY_POINT_GROUP = "device_agent_runtime.plugins"
|
||||
PLUGIN_MANIFEST_FILENAME = "plugin.json"
|
||||
EntryPointKind = Literal["driver", "tool", "skill"]
|
||||
_VALID_ENTRY_POINT_KINDS = ("driver", "tool", "skill")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginManifest:
|
||||
"""A plugin's declaration: who it is, what kind it is, where it lives."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
entry_point_kind: EntryPointKind
|
||||
target: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.name:
|
||||
raise PluginValidationError("plugin manifest 'name' is required")
|
||||
if not self.version:
|
||||
raise PluginValidationError("plugin manifest 'version' is required")
|
||||
if not self.target:
|
||||
raise PluginValidationError("plugin manifest 'target' is required")
|
||||
if self.entry_point_kind not in _VALID_ENTRY_POINT_KINDS:
|
||||
raise PluginValidationError(
|
||||
f"plugin manifest 'entry_point_kind' must be one of "
|
||||
f"{_VALID_ENTRY_POINT_KINDS}, got {self.entry_point_kind!r}"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"entry_point_kind": self.entry_point_kind,
|
||||
"target": self.target,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "PluginManifest":
|
||||
return cls(
|
||||
name=str(data["name"]),
|
||||
version=str(data["version"]),
|
||||
entry_point_kind=str(data["entry_point_kind"]), # type: ignore[arg-type]
|
||||
target=str(data["target"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveryResult:
|
||||
"""The outcome of running both discovery scans."""
|
||||
|
||||
registered: list[PluginManifest] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class PluginValidationError(ValueError):
|
||||
"""Raised when a plugin manifest fails schema validation."""
|
||||
|
||||
|
||||
class DuplicatePluginError(ValueError):
|
||||
"""Raised when a plugin name is already registered."""
|
||||
|
||||
|
||||
class DriverRegistryUnavailableError(RuntimeError):
|
||||
"""Raised when ``driver.registry.register_driver_type`` is not importable."""
|
||||
|
||||
|
||||
class PluginTargetResolutionError(RuntimeError):
|
||||
"""Raised when a plugin target cannot be resolved to a callable."""
|
||||
|
||||
|
||||
# Exception types `register()` can raise for a single bad manifest (validation
|
||||
# failure, name conflict, or driver-wiring failure). `discover()` catches only
|
||||
# these so one bad manifest is skipped/logged, per spec's "malformed manifest
|
||||
# file is skipped, not fatal" resilience intent -- anything else propagates as
|
||||
# a genuine programmer error.
|
||||
_PLUGIN_REGISTRATION_FAILURES = (
|
||||
PluginValidationError,
|
||||
DuplicatePluginError,
|
||||
DriverRegistryUnavailableError,
|
||||
PluginTargetResolutionError,
|
||||
)
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
"""Validates, persists, and wires plugins discovered or submitted directly."""
|
||||
|
||||
def __init__(self, store: "CloudStore") -> None:
|
||||
self.store = store
|
||||
|
||||
def register(self, manifest: PluginManifest) -> PluginManifest:
|
||||
"""Validate, persist, and (for drivers) wire a plugin manifest."""
|
||||
if self.store.get_plugin(manifest.name) is not None:
|
||||
raise DuplicatePluginError(
|
||||
f"a plugin named {manifest.name!r} is already registered"
|
||||
)
|
||||
wired = False
|
||||
if manifest.entry_point_kind == "driver":
|
||||
wired = self._wire_driver(manifest)
|
||||
# tool/skill-kind: stored with wired=False, no other resolution attempted.
|
||||
self.store.save_plugin(manifest, wired=wired)
|
||||
if not wired:
|
||||
logger.info(
|
||||
"plugin %r registered but not wired to any execution path",
|
||||
manifest.name,
|
||||
)
|
||||
return manifest
|
||||
|
||||
def list(self) -> list[tuple[PluginManifest, bool]]:
|
||||
return self.store.list_plugins()
|
||||
|
||||
def discover(self, scan_path: str | Path | None = None) -> DiscoveryResult:
|
||||
result = DiscoveryResult()
|
||||
for manifest in self.discover_entry_points():
|
||||
try:
|
||||
self.register(manifest)
|
||||
result.registered.append(manifest)
|
||||
except _PLUGIN_REGISTRATION_FAILURES as exc:
|
||||
result.errors.append(
|
||||
f"entry-point plugin {manifest.name!r}: {exc}"
|
||||
)
|
||||
if scan_path is not None:
|
||||
for manifest in self.discover_manifest_files(scan_path):
|
||||
try:
|
||||
self.register(manifest)
|
||||
result.registered.append(manifest)
|
||||
except _PLUGIN_REGISTRATION_FAILURES as exc:
|
||||
result.errors.append(
|
||||
f"manifest-file plugin {manifest.name!r}: {exc}"
|
||||
)
|
||||
return result
|
||||
|
||||
def discover_entry_points(self) -> list[PluginManifest]:
|
||||
manifests: list[PluginManifest] = []
|
||||
try:
|
||||
entry_points = importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)
|
||||
except TypeError:
|
||||
# Older Pythons: entry_points() returns a dict-like view.
|
||||
all_eps = importlib.metadata.entry_points()
|
||||
entry_points = all_eps.get(ENTRY_POINT_GROUP, []) # type: ignore[union-attr]
|
||||
for entry_point in entry_points:
|
||||
try:
|
||||
loaded = entry_point.load()
|
||||
except Exception as exc: # pragma: no cover - exercised via fake eps in tests
|
||||
logger.warning("entry point %r failed to load: %s", entry_point.name, exc)
|
||||
continue
|
||||
manifest = _coerce_to_manifest(loaded)
|
||||
if manifest is not None:
|
||||
manifests.append(manifest)
|
||||
return manifests
|
||||
|
||||
def discover_manifest_files(self, scan_path: str | Path) -> list[PluginManifest]:
|
||||
root = Path(scan_path)
|
||||
manifests: list[PluginManifest] = []
|
||||
if not root.exists():
|
||||
return manifests
|
||||
for path in sorted(root.rglob(PLUGIN_MANIFEST_FILENAME)):
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
manifest = PluginManifest.from_dict(data)
|
||||
except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
||||
logger.warning("malformed plugin manifest %s: %s", path, exc)
|
||||
continue
|
||||
manifests.append(manifest)
|
||||
return manifests
|
||||
|
||||
def _wire_driver(self, manifest: PluginManifest) -> bool:
|
||||
register_fn = _resolve_driver_register_function()
|
||||
if register_fn is None:
|
||||
raise DriverRegistryUnavailableError(
|
||||
"driver.registry.register_driver_type is not importable; "
|
||||
"the driver-registry extension point must be available to "
|
||||
"register driver-kind plugins"
|
||||
)
|
||||
builder = _resolve_target(manifest.target)
|
||||
register_fn(manifest.name, builder)
|
||||
return True
|
||||
|
||||
|
||||
def _resolve_driver_register_function():
|
||||
"""Return ``driver.registry.register_driver_type`` if importable, else None."""
|
||||
try:
|
||||
module = importlib.import_module("driver.registry")
|
||||
except ImportError:
|
||||
return None
|
||||
fn = getattr(module, "register_driver_type", None)
|
||||
if not callable(fn):
|
||||
return None
|
||||
return fn
|
||||
|
||||
|
||||
def _resolve_target(target: str):
|
||||
"""Resolve ``module.sub:attribute`` to a callable."""
|
||||
if ":" not in target:
|
||||
raise PluginTargetResolutionError(
|
||||
f"plugin target {target!r} must be 'module.path:attribute'"
|
||||
)
|
||||
module_path, _, attribute = target.partition(":")
|
||||
if not module_path or not attribute:
|
||||
raise PluginTargetResolutionError(
|
||||
f"plugin target {target!r} must be 'module.path:attribute'"
|
||||
)
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
except ImportError as exc:
|
||||
raise PluginTargetResolutionError(
|
||||
f"could not import plugin target module {module_path!r}: {exc}"
|
||||
) from exc
|
||||
obj = getattr(module, attribute, None)
|
||||
if obj is None or not callable(obj):
|
||||
raise PluginTargetResolutionError(
|
||||
f"plugin target {target!r} did not resolve to a callable"
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
def _coerce_to_manifest(loaded: Any) -> PluginManifest | None:
|
||||
if isinstance(loaded, PluginManifest):
|
||||
return loaded
|
||||
if isinstance(loaded, dict):
|
||||
try:
|
||||
return PluginManifest.from_dict(loaded)
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
if callable(loaded):
|
||||
try:
|
||||
result = loaded()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
return _coerce_to_manifest(result)
|
||||
return None
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Device pool: aggregating device state across hosts with staleness tracking.
|
||||
|
||||
Capability: ``device-pool``.
|
||||
|
||||
The pool never reaches out over the network. Each host's own agent calls
|
||||
``DevicePool.sync_host_devices(host_id, snapshot)`` to push its current
|
||||
``DeviceManager.list_devices()`` result. A host whose heartbeat goes stale
|
||||
has its devices lazily reported as ``unreachable`` on the next read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from core.models import Device, utc_now
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cloud.config import CloudConfig
|
||||
from cloud.store import CloudStore
|
||||
|
||||
|
||||
PooledDeviceStatus = Literal["idle", "busy", "offline", "error", "unreachable"]
|
||||
|
||||
# Statuses that map 1:1 from a host's snapshot. ``unreachable`` is pool-only.
|
||||
_HOST_REPORTED_STATUSES = ("idle", "busy", "offline", "error")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostRegistration:
|
||||
"""A host process that has registered itself with the pool."""
|
||||
|
||||
host_id: str
|
||||
address: str | None
|
||||
last_seen_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PooledDevice:
|
||||
"""A device owned by a registered host, as seen by the pool."""
|
||||
|
||||
device_id: str
|
||||
host_id: str
|
||||
driver_type: str
|
||||
status: PooledDeviceStatus
|
||||
capability_tags: list[str] = field(default_factory=list)
|
||||
synced_at: datetime | None = None
|
||||
|
||||
|
||||
class DevicePool:
|
||||
"""Aggregates ``Device`` snapshots pushed by many host processes."""
|
||||
|
||||
def __init__(self, store: "CloudStore", config: "CloudConfig") -> None:
|
||||
self.store = store
|
||||
self.config = config
|
||||
|
||||
def sync_host_devices(
|
||||
self,
|
||||
host_id: str,
|
||||
snapshot: list[Device],
|
||||
*,
|
||||
address: str | None = None,
|
||||
) -> None:
|
||||
"""Push a host's current device snapshot into the pool.
|
||||
|
||||
Updates the host's ``last_seen_at`` and atomically replaces its
|
||||
previously-stored device rows with the new snapshot. Devices from
|
||||
other hosts are untouched.
|
||||
"""
|
||||
now = utc_now()
|
||||
self.store.upsert_host(host_id, address=address, last_seen_at=now)
|
||||
devices = [self._to_pooled(device, host_id, now) for device in snapshot]
|
||||
self.store.replace_host_devices(host_id, devices)
|
||||
|
||||
def list_devices(self) -> list[PooledDevice]:
|
||||
devices = self.store.list_devices()
|
||||
if not devices:
|
||||
return []
|
||||
hosts = {h.host_id: h for h in self.store.list_hosts()}
|
||||
now = utc_now()
|
||||
result: list[PooledDevice] = []
|
||||
for device in devices:
|
||||
host = hosts.get(device.host_id)
|
||||
if host is not None and self._is_stale(host, now):
|
||||
device = self._as_unreachable(device)
|
||||
result.append(device)
|
||||
return result
|
||||
|
||||
def get_device(self, device_id: str) -> PooledDevice | None:
|
||||
device = self.store.get_device(device_id)
|
||||
if device is None:
|
||||
return None
|
||||
host = self.store.get_host(device.host_id)
|
||||
if host is not None and self._is_stale(host, utc_now()):
|
||||
return self._as_unreachable(device)
|
||||
return device
|
||||
|
||||
def list_hosts(self) -> list[HostRegistration]:
|
||||
return self.store.list_hosts()
|
||||
|
||||
def _to_pooled(
|
||||
self,
|
||||
device: Device,
|
||||
host_id: str,
|
||||
synced_at: datetime,
|
||||
) -> PooledDevice:
|
||||
raw_status = device.status if device.status in _HOST_REPORTED_STATUSES else "idle"
|
||||
tags = list(device.capability_tags or [])
|
||||
return PooledDevice(
|
||||
device_id=device.id,
|
||||
host_id=host_id,
|
||||
driver_type=device.driver_type,
|
||||
status=raw_status, # type: ignore[arg-type]
|
||||
capability_tags=tags,
|
||||
synced_at=synced_at,
|
||||
)
|
||||
|
||||
def _is_stale(self, host: HostRegistration, now: datetime) -> bool:
|
||||
age = (now - host.last_seen_at).total_seconds()
|
||||
return age > self.config.stale_after_seconds
|
||||
|
||||
def _as_unreachable(self, device: PooledDevice) -> PooledDevice:
|
||||
if device.status == "unreachable":
|
||||
return device
|
||||
return replace(device, status="unreachable")
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Task scheduler: bounded queue + pluggable assignment strategy.
|
||||
|
||||
Capability: ``task-scheduler``.
|
||||
|
||||
Mirrors the "string key -> swappable implementation" registry shape already used
|
||||
by ``driver/registry.py``, ``perception/provider.py``, and ``workflow/conditions.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable
|
||||
from uuid import uuid4
|
||||
|
||||
from core.models import utc_now
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cloud.config import CloudConfig
|
||||
from cloud.dispatch import Assignment
|
||||
from cloud.pool import DevicePool, PooledDevice
|
||||
from cloud.store import CloudStore
|
||||
|
||||
|
||||
ScheduledTaskStatus = Literal["queued", "assigned", "dispatched", "done", "failed"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskConstraints:
|
||||
"""Optional device constraints attached to a task submission."""
|
||||
|
||||
driver_type: str | None = None
|
||||
capability_tags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduledTask:
|
||||
"""A task submitted to the cloud scheduler, awaiting or undergoing assignment."""
|
||||
|
||||
id: str
|
||||
goal: str | None
|
||||
workflow_definition_id: str | None
|
||||
constraints: TaskConstraints
|
||||
status: ScheduledTaskStatus = "queued"
|
||||
assigned_device_id: str | None = None
|
||||
assigned_host_id: str | None = None
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AssignmentStrategy(Protocol):
|
||||
"""Selects one device from a pre-filterd list of matching candidates."""
|
||||
|
||||
def select(
|
||||
self,
|
||||
task: ScheduledTask,
|
||||
candidates: "list[PooledDevice]",
|
||||
) -> "PooledDevice | None": ...
|
||||
|
||||
|
||||
class FifoMatchStrategy:
|
||||
"""Default strategy: return the first candidate, or None if empty.
|
||||
|
||||
The caller is responsible for pre-filtering to idle, constraint-matching
|
||||
devices in submission order. This keeps the strategy trivially replaceable.
|
||||
"""
|
||||
|
||||
def select(
|
||||
self,
|
||||
task: ScheduledTask,
|
||||
candidates: "list[PooledDevice]",
|
||||
) -> "PooledDevice | None":
|
||||
if not candidates:
|
||||
return None
|
||||
return candidates[0]
|
||||
|
||||
|
||||
FIFO_MATCH_STRATEGY_NAME = "fifo_match"
|
||||
DEFAULT_STRATEGIES: dict[str, AssignmentStrategy] = {
|
||||
FIFO_MATCH_STRATEGY_NAME: FifoMatchStrategy(),
|
||||
}
|
||||
|
||||
|
||||
class UnknownAssignmentStrategyError(ValueError):
|
||||
"""Raised when the configured default strategy is not in the registry."""
|
||||
|
||||
|
||||
class QueueFullError(RuntimeError):
|
||||
"""Raised when a submission would exceed ``config.max_queue_depth``."""
|
||||
|
||||
|
||||
class TaskSubmissionValidationError(ValueError):
|
||||
"""Raised when a submission has neither a goal nor a workflow_definition_id."""
|
||||
|
||||
|
||||
class TaskScheduler:
|
||||
"""Accepts task submissions and assigns queued tasks to idle devices."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pool: "DevicePool",
|
||||
store: "CloudStore",
|
||||
config: "CloudConfig",
|
||||
*,
|
||||
strategies: dict[str, AssignmentStrategy] | None = None,
|
||||
) -> None:
|
||||
self.pool = pool
|
||||
self.store = store
|
||||
self.config = config
|
||||
self._strategies = dict(strategies) if strategies is not None else dict(DEFAULT_STRATEGIES)
|
||||
if config.default_assignment_strategy not in self._strategies:
|
||||
raise UnknownAssignmentStrategyError(
|
||||
f"unknown assignment strategy {config.default_assignment_strategy!r}; "
|
||||
f"registered strategies: {sorted(self._strategies)}"
|
||||
)
|
||||
|
||||
def submit(
|
||||
self,
|
||||
goal: str | None = None,
|
||||
workflow_definition_id: str | None = None,
|
||||
constraints: TaskConstraints | None = None,
|
||||
) -> str:
|
||||
if not goal and not workflow_definition_id:
|
||||
raise TaskSubmissionValidationError(
|
||||
"a submission must specify either a goal or a workflow_definition_id"
|
||||
)
|
||||
if self.store.count_queued_tasks() >= self.config.max_queue_depth:
|
||||
raise QueueFullError(
|
||||
f"task queue is full ({self.config.max_queue_depth} queued)"
|
||||
)
|
||||
task = ScheduledTask(
|
||||
id=uuid4().hex,
|
||||
goal=goal,
|
||||
workflow_definition_id=workflow_definition_id,
|
||||
constraints=constraints or TaskConstraints(),
|
||||
status="queued",
|
||||
created_at=utc_now(),
|
||||
)
|
||||
self.store.enqueue_task(task)
|
||||
return task.id
|
||||
|
||||
def assign(self) -> "list[Assignment]":
|
||||
from cloud.dispatch import Assignment
|
||||
|
||||
strategy = self._strategies[self.config.default_assignment_strategy]
|
||||
assignments: list[Assignment] = []
|
||||
queued = self.store.list_queued_tasks() # ordered oldest-first
|
||||
if not queued:
|
||||
return assignments
|
||||
|
||||
devices = self.pool.list_devices()
|
||||
assigned_device_ids: set[str] = set()
|
||||
for task in queued:
|
||||
candidates = [
|
||||
device
|
||||
for device in devices
|
||||
if device.device_id not in assigned_device_ids
|
||||
and device.status == "idle"
|
||||
and _matches(device, task.constraints)
|
||||
]
|
||||
selected = strategy.select(task, candidates)
|
||||
if selected is None:
|
||||
continue
|
||||
assigned_device_ids.add(selected.device_id)
|
||||
self.store.update_task(
|
||||
task.id,
|
||||
status="assigned",
|
||||
assigned_device_id=selected.device_id,
|
||||
assigned_host_id=selected.host_id,
|
||||
)
|
||||
assignments.append(
|
||||
Assignment(
|
||||
task_id=task.id,
|
||||
device_id=selected.device_id,
|
||||
host_id=selected.host_id,
|
||||
goal=task.goal,
|
||||
workflow_definition_id=task.workflow_definition_id,
|
||||
)
|
||||
)
|
||||
return assignments
|
||||
|
||||
|
||||
def _matches(device: "PooledDevice", constraints: TaskConstraints) -> bool:
|
||||
if constraints.driver_type and device.driver_type != constraints.driver_type:
|
||||
return False
|
||||
if constraints.capability_tags:
|
||||
device_tags = set(device.capability_tags)
|
||||
if not all(tag in device_tags for tag in constraints.capability_tags):
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Public platform SDK: versioned REST API and Python client for external integrators."""
|
||||
|
||||
__all__ = [
|
||||
"CloudClient",
|
||||
"create_cloud_router",
|
||||
"AuthProvider",
|
||||
"NullAuthProvider",
|
||||
"Principal",
|
||||
]
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Versioned REST API for external integrators.
|
||||
|
||||
Capability: ``platform-sdk``.
|
||||
|
||||
Mirrors ``api/console.py``'s ``create_console_router`` shape: a factory that
|
||||
returns a ``fastapi.APIRouter`` mounted under a versioned ``/v1`` prefix.
|
||||
Every route flows through an ``AuthProvider`` hook (no-op default) so real
|
||||
authentication can be added later without changing route signatures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
from cloud.sdk.models import (
|
||||
DeviceResponse,
|
||||
ErrorResponse,
|
||||
HostResponse,
|
||||
PluginRegistrationRequest,
|
||||
PluginResponse,
|
||||
TaskStatusResponse,
|
||||
TaskSubmissionRequest,
|
||||
TaskSubmissionResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cloud.plugins import PluginRegistry
|
||||
from cloud.pool import DevicePool
|
||||
from cloud.scheduler import TaskConstraints, TaskScheduler
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Principal:
|
||||
"""An authenticated principal. ``anonymous`` for the NullAuthProvider."""
|
||||
|
||||
id: str = "anonymous"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuthProvider(Protocol):
|
||||
"""Returns a Principal if the request is allowed, None to reject."""
|
||||
|
||||
def authenticate(self, request: object) -> Principal | None: ...
|
||||
|
||||
|
||||
class NullAuthProvider:
|
||||
"""Default auth provider: every caller is anonymous-and-allowed."""
|
||||
|
||||
def authenticate(self, request: object) -> Principal | None:
|
||||
return Principal()
|
||||
|
||||
|
||||
def create_cloud_router(
|
||||
*,
|
||||
pool: "DevicePool",
|
||||
scheduler: "TaskScheduler",
|
||||
plugin_registry: "PluginRegistry",
|
||||
auth_provider: AuthProvider | None = None,
|
||||
version_prefix: str = "/v1",
|
||||
) -> APIRouter:
|
||||
"""Build the ``/v1`` APIRouter exposing the platform SDK surface."""
|
||||
auth = auth_provider or NullAuthProvider()
|
||||
router = APIRouter(prefix=version_prefix, tags=["cloud-platform"])
|
||||
|
||||
def _authorize(request: Request) -> Principal:
|
||||
principal = auth.authenticate(request)
|
||||
if principal is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="unauthorized",
|
||||
)
|
||||
return principal
|
||||
|
||||
@router.post(
|
||||
"/tasks",
|
||||
response_model=TaskSubmissionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def submit_task(
|
||||
payload: TaskSubmissionRequest,
|
||||
request: Request,
|
||||
) -> TaskSubmissionResponse:
|
||||
_authorize(request)
|
||||
task_constraints = _build_constraints(payload.constraints)
|
||||
try:
|
||||
task_id = scheduler.submit(
|
||||
goal=payload.goal,
|
||||
workflow_definition_id=payload.workflow_definition_id,
|
||||
constraints=task_constraints,
|
||||
)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return TaskSubmissionResponse(task_id=task_id)
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=TaskStatusResponse)
|
||||
def get_task_status(task_id: str, request: Request) -> TaskStatusResponse:
|
||||
_authorize(request)
|
||||
task = scheduler.store.get_task(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"task {task_id!r} not found",
|
||||
)
|
||||
return TaskStatusResponse(
|
||||
id=task.id,
|
||||
status=task.status,
|
||||
goal=task.goal,
|
||||
workflow_definition_id=task.workflow_definition_id,
|
||||
assigned_device_id=task.assigned_device_id,
|
||||
assigned_host_id=task.assigned_host_id,
|
||||
)
|
||||
|
||||
@router.get("/devices", response_model=list[DeviceResponse])
|
||||
def list_devices(request: Request) -> list[DeviceResponse]:
|
||||
_authorize(request)
|
||||
return [
|
||||
DeviceResponse(
|
||||
device_id=d.device_id,
|
||||
host_id=d.host_id,
|
||||
driver_type=d.driver_type,
|
||||
status=d.status,
|
||||
capability_tags=list(d.capability_tags),
|
||||
)
|
||||
for d in pool.list_devices()
|
||||
]
|
||||
|
||||
@router.get("/hosts", response_model=list[HostResponse])
|
||||
def list_hosts(request: Request) -> list[HostResponse]:
|
||||
_authorize(request)
|
||||
return [
|
||||
HostResponse(
|
||||
host_id=h.host_id,
|
||||
address=h.address,
|
||||
last_seen_at=h.last_seen_at.isoformat() if h.last_seen_at else "",
|
||||
)
|
||||
for h in pool.list_hosts()
|
||||
]
|
||||
|
||||
@router.get("/plugins", response_model=list[PluginResponse])
|
||||
def list_plugins(request: Request) -> list[PluginResponse]:
|
||||
_authorize(request)
|
||||
return [
|
||||
PluginResponse(
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
entry_point_kind=manifest.entry_point_kind,
|
||||
target=manifest.target,
|
||||
wired=wired,
|
||||
)
|
||||
for manifest, wired in plugin_registry.list()
|
||||
]
|
||||
|
||||
@router.post(
|
||||
"/plugins",
|
||||
response_model=PluginResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse},
|
||||
status.HTTP_409_CONFLICT: {"model": ErrorResponse},
|
||||
},
|
||||
)
|
||||
def register_plugin(
|
||||
payload: PluginRegistrationRequest,
|
||||
request: Request,
|
||||
) -> PluginResponse:
|
||||
_authorize(request)
|
||||
from cloud.plugins import (
|
||||
DriverRegistryUnavailableError,
|
||||
DuplicatePluginError,
|
||||
PluginManifest,
|
||||
PluginValidationError,
|
||||
)
|
||||
|
||||
manifest = PluginManifest(
|
||||
name=payload.name,
|
||||
version=payload.version,
|
||||
entry_point_kind=payload.entry_point_kind,
|
||||
target=payload.target,
|
||||
)
|
||||
try:
|
||||
plugin_registry.register(manifest)
|
||||
except DuplicatePluginError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except DriverRegistryUnavailableError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except (PluginValidationError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
stored = plugin_registry.store.get_plugin(manifest.name)
|
||||
wired = stored[1] if stored is not None else False
|
||||
return PluginResponse(
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
entry_point_kind=manifest.entry_point_kind,
|
||||
target=manifest.target,
|
||||
wired=wired,
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _build_constraints(model):
|
||||
from cloud.scheduler import TaskConstraints
|
||||
|
||||
return TaskConstraints(
|
||||
driver_type=model.driver_type,
|
||||
capability_tags=list(model.capability_tags),
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Thin Python SDK client for the ``/v1`` REST API.
|
||||
|
||||
Capability: ``platform-sdk``.
|
||||
|
||||
Uses ``httpx`` directly so the client can talk to any deployed cloud-runtime
|
||||
process over HTTP, or to a FastAPI ``TestClient`` instance in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class CloudClient:
|
||||
"""A minimal Python wrapper for the platform SDK's ``/v1`` routes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
http_client: httpx.Client | Any = None,
|
||||
api_prefix: str = "/v1",
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_prefix = api_prefix.rstrip("/")
|
||||
if http_client is None:
|
||||
self._http = httpx.Client(base_url=self._base_url)
|
||||
self._owns_client = True
|
||||
else:
|
||||
self._http = http_client
|
||||
self._owns_client = False
|
||||
|
||||
def close(self) -> None:
|
||||
if self._owns_client:
|
||||
self._http.close()
|
||||
|
||||
def __enter__(self) -> "CloudClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
self.close()
|
||||
|
||||
# ------------------------------------------------------------------- tasks
|
||||
|
||||
def submit_task(
|
||||
self,
|
||||
*,
|
||||
goal: str | None = None,
|
||||
workflow_definition_id: str | None = None,
|
||||
driver_type: str | None = None,
|
||||
capability_tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"goal": goal,
|
||||
"workflow_definition_id": workflow_definition_id,
|
||||
}
|
||||
if driver_type is not None or capability_tags is not None:
|
||||
payload["constraints"] = {
|
||||
"driver_type": driver_type,
|
||||
"capability_tags": list(capability_tags or []),
|
||||
}
|
||||
resp = self._http.post(self._url("/tasks"), json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def get_task_status(self, task_id: str) -> dict[str, Any]:
|
||||
resp = self._http.get(self._url(f"/tasks/{task_id}"))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ----------------------------------------------------------------- devices
|
||||
|
||||
def list_devices(self) -> list[dict[str, Any]]:
|
||||
resp = self._http.get(self._url("/devices"))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def list_hosts(self) -> list[dict[str, Any]]:
|
||||
resp = self._http.get(self._url("/hosts"))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ----------------------------------------------------------------- plugins
|
||||
|
||||
def list_plugins(self) -> list[dict[str, Any]]:
|
||||
resp = self._http.get(self._url("/plugins"))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def register_plugin(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
version: str,
|
||||
entry_point_kind: str,
|
||||
target: str,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"name": name,
|
||||
"version": version,
|
||||
"entry_point_kind": entry_point_kind,
|
||||
"target": target,
|
||||
}
|
||||
resp = self._http.post(self._url("/plugins"), json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self._base_url}{self._api_prefix}{path}"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Pydantic request/response models for the platform SDK REST API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TaskConstraintsModel(BaseModel):
|
||||
driver_type: str | None = None
|
||||
capability_tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TaskSubmissionRequest(BaseModel):
|
||||
goal: str | None = None
|
||||
workflow_definition_id: str | None = None
|
||||
constraints: TaskConstraintsModel = Field(default_factory=TaskConstraintsModel)
|
||||
|
||||
|
||||
class TaskSubmissionResponse(BaseModel):
|
||||
task_id: str
|
||||
|
||||
|
||||
class TaskStatusResponse(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
goal: str | None = None
|
||||
workflow_definition_id: str | None = None
|
||||
assigned_device_id: str | None = None
|
||||
assigned_host_id: str | None = None
|
||||
|
||||
|
||||
class DeviceResponse(BaseModel):
|
||||
device_id: str
|
||||
host_id: str
|
||||
driver_type: str
|
||||
status: str
|
||||
capability_tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HostResponse(BaseModel):
|
||||
host_id: str
|
||||
address: str | None = None
|
||||
last_seen_at: str
|
||||
|
||||
|
||||
class PluginRegistrationRequest(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
entry_point_kind: Literal["driver", "tool", "skill"]
|
||||
target: str
|
||||
|
||||
|
||||
class PluginResponse(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
entry_point_kind: str
|
||||
target: str
|
||||
wired: bool
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
@@ -0,0 +1,415 @@
|
||||
"""SQLite-backed store for cloud runtime state.
|
||||
|
||||
Owns ``cloud/cloud.sqlite3`` with four tables: ``host_registrations``,
|
||||
``pooled_devices``, ``scheduled_tasks``, and ``plugins``. Uses the same
|
||||
connect-per-call ``sqlite3`` pattern as ``storage/task_metadata.py`` and
|
||||
``workflow/store.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from core.models import utc_now
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cloud.pool import HostRegistration, PooledDevice
|
||||
from cloud.plugins import PluginManifest
|
||||
from cloud.scheduler import ScheduledTask
|
||||
|
||||
|
||||
class CloudStore:
|
||||
"""Persisted state for the cloud runtime (hosts, devices, tasks, plugins)."""
|
||||
|
||||
def __init__(self, db_path: str | Path = "cloud/cloud.sqlite3") -> None:
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._ensure_schema()
|
||||
|
||||
# ------------------------------------------------------------------ hosts
|
||||
|
||||
def upsert_host(
|
||||
self,
|
||||
host_id: str,
|
||||
*,
|
||||
address: str | None,
|
||||
last_seen_at: Any,
|
||||
) -> None:
|
||||
"""Insert or update a host row.
|
||||
|
||||
If ``address`` is None and the host already exists, the existing
|
||||
address is preserved (a heartbeat sync should not blow away a
|
||||
previously-registered address).
|
||||
"""
|
||||
with self._connect() as connection:
|
||||
existing = connection.execute(
|
||||
"select address from host_registrations where host_id = ?",
|
||||
(host_id,),
|
||||
).fetchone()
|
||||
preserved_address = (
|
||||
existing["address"] if (address is None and existing is not None) else address
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
insert into host_registrations (host_id, address, last_seen_at)
|
||||
values (?, ?, ?)
|
||||
on conflict(host_id) do update set
|
||||
address = excluded.address,
|
||||
last_seen_at = excluded.last_seen_at
|
||||
""",
|
||||
(host_id, preserved_address, _iso(last_seen_at)),
|
||||
)
|
||||
|
||||
def replace_host_devices(
|
||||
self,
|
||||
host_id: str,
|
||||
devices: "list[PooledDevice]",
|
||||
) -> None:
|
||||
"""Atomically replace one host's device rows with the given list."""
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"delete from pooled_devices where host_id = ?",
|
||||
(host_id,),
|
||||
)
|
||||
connection.executemany(
|
||||
"""
|
||||
insert into pooled_devices (
|
||||
device_id, host_id, driver_type, status,
|
||||
capability_tags_json, synced_at
|
||||
) values (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
d.device_id,
|
||||
d.host_id,
|
||||
d.driver_type,
|
||||
d.status,
|
||||
json.dumps(list(d.capability_tags), ensure_ascii=False),
|
||||
_iso(d.synced_at),
|
||||
)
|
||||
for d in devices
|
||||
],
|
||||
)
|
||||
|
||||
def list_hosts(self) -> "list[HostRegistration]":
|
||||
from cloud.pool import HostRegistration
|
||||
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"select host_id, address, last_seen_at from host_registrations"
|
||||
).fetchall()
|
||||
return [_row_to_host(row) for row in rows]
|
||||
|
||||
def get_host(self, host_id: str) -> "HostRegistration | None":
|
||||
from cloud.pool import HostRegistration
|
||||
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"select host_id, address, last_seen_at from host_registrations where host_id = ?",
|
||||
(host_id,),
|
||||
).fetchone()
|
||||
return _row_to_host(row) if row else None
|
||||
|
||||
def list_devices(self) -> "list[PooledDevice]":
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
select device_id, host_id, driver_type, status,
|
||||
capability_tags_json, synced_at
|
||||
from pooled_devices
|
||||
"""
|
||||
).fetchall()
|
||||
return [_row_to_device(row) for row in rows]
|
||||
|
||||
def get_device(self, device_id: str) -> "PooledDevice | None":
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
select device_id, host_id, driver_type, status,
|
||||
capability_tags_json, synced_at
|
||||
from pooled_devices where device_id = ?
|
||||
""",
|
||||
(device_id,),
|
||||
).fetchone()
|
||||
return _row_to_device(row) if row else None
|
||||
|
||||
# ----------------------------------------------------------- scheduled tasks
|
||||
|
||||
def enqueue_task(self, task: "ScheduledTask") -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
insert into scheduled_tasks (
|
||||
id, goal, workflow_definition_id,
|
||||
constraints_json, status,
|
||||
assigned_device_id, assigned_host_id, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
task.id,
|
||||
task.goal,
|
||||
task.workflow_definition_id,
|
||||
json.dumps(asdict(task.constraints), ensure_ascii=False),
|
||||
task.status,
|
||||
task.assigned_device_id,
|
||||
task.assigned_host_id,
|
||||
_iso(task.created_at),
|
||||
),
|
||||
)
|
||||
|
||||
def list_queued_tasks(self) -> "list[ScheduledTask]":
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
select id, goal, workflow_definition_id, constraints_json,
|
||||
status, assigned_device_id, assigned_host_id, created_at
|
||||
from scheduled_tasks
|
||||
where status = 'queued'
|
||||
order by created_at asc, id asc
|
||||
"""
|
||||
).fetchall()
|
||||
return [_row_to_task(row) for row in rows]
|
||||
|
||||
def get_task(self, task_id: str) -> "ScheduledTask | None":
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
select id, goal, workflow_definition_id, constraints_json,
|
||||
status, assigned_device_id, assigned_host_id, created_at
|
||||
from scheduled_tasks where id = ?
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
return _row_to_task(row) if row else None
|
||||
|
||||
def update_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
assigned_device_id: str | None = None,
|
||||
assigned_host_id: str | None = None,
|
||||
) -> None:
|
||||
updates: dict[str, Any] = {}
|
||||
if status is not None:
|
||||
updates["status"] = status
|
||||
if assigned_device_id is not None:
|
||||
updates["assigned_device_id"] = assigned_device_id
|
||||
if assigned_host_id is not None:
|
||||
updates["assigned_host_id"] = assigned_host_id
|
||||
if not updates:
|
||||
return
|
||||
assignments = ", ".join(f"{key} = ?" for key in updates)
|
||||
values = [*updates.values(), task_id]
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
f"update scheduled_tasks set {assignments} where id = ?",
|
||||
values,
|
||||
)
|
||||
|
||||
def count_queued_tasks(self) -> int:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"select count(*) as count from scheduled_tasks where status = 'queued'"
|
||||
).fetchone()
|
||||
return int(row["count"])
|
||||
|
||||
# ----------------------------------------------------------------- plugins
|
||||
|
||||
def save_plugin(
|
||||
self,
|
||||
manifest: "PluginManifest",
|
||||
*,
|
||||
wired: bool,
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
insert into plugins (
|
||||
name, version, entry_point_kind, target, wired
|
||||
) values (?, ?, ?, ?, ?)
|
||||
on conflict(name) do update set
|
||||
version = excluded.version,
|
||||
entry_point_kind = excluded.entry_point_kind,
|
||||
target = excluded.target,
|
||||
wired = excluded.wired
|
||||
""",
|
||||
(
|
||||
manifest.name,
|
||||
manifest.version,
|
||||
manifest.entry_point_kind,
|
||||
manifest.target,
|
||||
1 if wired else 0,
|
||||
),
|
||||
)
|
||||
|
||||
def list_plugins(self) -> "list[tuple[PluginManifest, bool]]":
|
||||
from cloud.plugins import PluginManifest
|
||||
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"select name, version, entry_point_kind, target, wired from plugins"
|
||||
).fetchall()
|
||||
return [
|
||||
(
|
||||
PluginManifest(
|
||||
name=row["name"],
|
||||
version=row["version"],
|
||||
entry_point_kind=row["entry_point_kind"],
|
||||
target=row["target"],
|
||||
),
|
||||
bool(row["wired"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def get_plugin(self, name: str) -> "tuple[PluginManifest, bool] | None":
|
||||
from cloud.plugins import PluginManifest
|
||||
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"select name, version, entry_point_kind, target, wired from plugins where name = ?",
|
||||
(name,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
manifest = PluginManifest(
|
||||
name=row["name"],
|
||||
version=row["version"],
|
||||
entry_point_kind=row["entry_point_kind"],
|
||||
target=row["target"],
|
||||
)
|
||||
return (manifest, bool(row["wired"]))
|
||||
|
||||
# ------------------------------------------------------------------- schema
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
create table if not exists host_registrations (
|
||||
host_id text primary key,
|
||||
address text,
|
||||
last_seen_at text not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
create table if not exists pooled_devices (
|
||||
device_id text not null,
|
||||
host_id text not null,
|
||||
driver_type text not null,
|
||||
status text not null,
|
||||
capability_tags_json text not null,
|
||||
synced_at text,
|
||||
primary key (host_id, device_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
create table if not exists scheduled_tasks (
|
||||
id text primary key,
|
||||
goal text,
|
||||
workflow_definition_id text,
|
||||
constraints_json text not null,
|
||||
status text not null,
|
||||
assigned_device_id text,
|
||||
assigned_host_id text,
|
||||
created_at text not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
create table if not exists plugins (
|
||||
name text primary key,
|
||||
version text not null,
|
||||
entry_point_kind text not null,
|
||||
target text not null,
|
||||
wired integer not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.db_path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
|
||||
def _iso(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _parse_dt(value: Any):
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _row_to_host(row: sqlite3.Row):
|
||||
from cloud.pool import HostRegistration
|
||||
|
||||
return HostRegistration(
|
||||
host_id=row["host_id"],
|
||||
address=row["address"],
|
||||
last_seen_at=_parse_dt(row["last_seen_at"]) or utc_now(),
|
||||
)
|
||||
|
||||
|
||||
def _row_to_device(row: sqlite3.Row):
|
||||
from cloud.pool import PooledDevice
|
||||
|
||||
tags_raw = row["capability_tags_json"]
|
||||
try:
|
||||
tags = list(json.loads(tags_raw)) if tags_raw else []
|
||||
except (TypeError, ValueError):
|
||||
tags = []
|
||||
return PooledDevice(
|
||||
device_id=row["device_id"],
|
||||
host_id=row["host_id"],
|
||||
driver_type=row["driver_type"],
|
||||
status=row["status"],
|
||||
capability_tags=tags,
|
||||
synced_at=_parse_dt(row["synced_at"]),
|
||||
)
|
||||
|
||||
|
||||
def _row_to_task(row: sqlite3.Row):
|
||||
from cloud.scheduler import ScheduledTask, TaskConstraints
|
||||
|
||||
try:
|
||||
constraints_data = json.loads(row["constraints_json"]) if row["constraints_json"] else {}
|
||||
except (TypeError, ValueError):
|
||||
constraints_data = {}
|
||||
constraints = TaskConstraints(
|
||||
driver_type=constraints_data.get("driver_type"),
|
||||
capability_tags=list(constraints_data.get("capability_tags") or []),
|
||||
)
|
||||
return ScheduledTask(
|
||||
id=row["id"],
|
||||
goal=row["goal"],
|
||||
workflow_definition_id=row["workflow_definition_id"],
|
||||
constraints=constraints,
|
||||
status=row["status"],
|
||||
assigned_device_id=row["assigned_device_id"],
|
||||
assigned_host_id=row["assigned_host_id"],
|
||||
created_at=_parse_dt(row["created_at"]) or utc_now(),
|
||||
)
|
||||
Reference in New Issue
Block a user