This commit is contained in:
2026-07-06 23:44:18 +08:00
parent a453d9e6ba
commit d899b875ce
23 changed files with 3274 additions and 51 deletions
+25
View File
@@ -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",
]
+35
View File
@@ -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"
+103
View File
@@ -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"
+245
View File
@@ -0,0 +1,245 @@
"""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."""
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 (PluginValidationError, DuplicatePluginError) 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 (PluginValidationError, DuplicatePluginError) 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
+126
View File
@@ -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(getattr(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")
+190
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
"""Public platform SDK: versioned REST API and Python client for external integrators."""
__all__ = [
"CloudClient",
"create_cloud_router",
"AuthProvider",
"NullAuthProvider",
"Principal",
]
+222
View File
@@ -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),
)
+113
View File
@@ -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}"
+64
View File
@@ -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
+414
View File
@@ -0,0 +1,414 @@
"""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 primary key,
host_id text not null,
driver_type text not null,
status text not null,
capability_tags_json text not null,
synced_at text
)
"""
)
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(),
)