## 1. Package scaffolding - [ ] 1.1 Create the `cloud/` package (`__init__.py`, `pool.py`, `scheduler.py`, `dispatch.py`, `plugins.py`, `store.py`, `config.py`) and the `cloud/sdk/` sub-package (`__init__.py`, `api.py`, `client.py`, `models.py`) - [ ] 1.2 Add `cloud*` to `[tool.setuptools.packages.find].include` in `pyproject.toml` - [ ] 1.3 Promote `httpx` from `[dependency-groups].dev` to `[project].dependencies` in `pyproject.toml` (needed at runtime by `cloud/sdk/client.py`) - [ ] 1.4 Implement `cloud/config.py`: `CloudConfig` dataclass with `sync_interval_seconds`, `stale_after_seconds`, `max_queue_depth`, `default_assignment_strategy`, `api_version_prefix` (`"/v1"`), `db_path` (`cloud/cloud.sqlite3`), each with a conservative documented default - [ ] 1.5 Extend the project's smoke test (that imports every package) to import `cloud` and `cloud.sdk` ## 2. Device pool data model and store (capability: device-pool) - [ ] 2.1 Implement `cloud/pool.py`'s data types: `HostRegistration{host_id, address, last_seen_at}`, `PooledDevice{device_id, host_id, driver_type, status, capability_tags, synced_at}` - [ ] 2.2 Implement `cloud/store.py`'s `CloudStore(db_path)` with schema creation for `host_registrations` and `pooled_devices` tables (connect-per-call `sqlite3` pattern, following `storage/task_metadata.py`/`workflow/store.py`) - [ ] 2.3 Implement `CloudStore.upsert_host(host_id, address, last_seen_at)` and `CloudStore.replace_host_devices(host_id, devices: list[PooledDevice])` (atomic replace of one host's device rows per sync) - [ ] 2.4 Implement `CloudStore.list_hosts()`, `CloudStore.list_devices()`, `CloudStore.get_device(device_id)` - [ ] 2.5 Write unit tests for `CloudStore`: host upsert + device replace round-trip; a second sync for the same host fully replaces (not appends to) its device rows; devices from two different hosts coexist without collision ## 3. DevicePool aggregation and staleness (capability: device-pool) - [ ] 3.1 Implement `DevicePool(store: CloudStore, config: CloudConfig)` with `sync_host_devices(host_id, snapshot: list[core.models.Device])`, converting each `Device` into a `PooledDevice` and calling `CloudStore.upsert_host()`/`replace_host_devices()` - [ ] 3.2 Implement `DevicePool.list_devices() -> list[PooledDevice]` and `DevicePool.get_device(device_id) -> PooledDevice | None`, both computing per-host staleness lazily at call time (`now - last_seen_at > config.stale_after_seconds` implies status `unreachable`, overriding the last-synced status) rather than via a background thread - [ ] 3.3 Implement `DevicePool.list_hosts() -> list[HostRegistration]` - [ ] 3.4 Write unit tests: new host sync creates a `HostRegistration` + `PooledDevice`s; re-sync updates last-seen and replaces devices; a host whose last-seen exceeds the staleness threshold reports all its devices `unreachable` on the next `list_devices()`/`get_device()` call; a host that resyncs after being stale immediately stops being reported unreachable; lookup for an unknown `device_id` returns `None`; empty pool returns an empty list ## 4. TaskScheduler queue and assignment (capability: task-scheduler) - [ ] 4.1 Implement `cloud/scheduler.py`'s data types: `TaskConstraints{driver_type: str | None, capability_tags: list[str]}`, `ScheduledTask{id, goal: str | None, workflow_definition_id: str | None, constraints, status, assigned_device_id, assigned_host_id, created_at}` - [ ] 4.2 Add `scheduled_tasks` table + `CloudStore.enqueue_task()`, `CloudStore.list_queued_tasks()`, `CloudStore.update_task()`, `CloudStore.get_task(task_id)` to `cloud/store.py` - [ ] 4.3 Implement `AssignmentStrategy` protocol/ABC (`select(task, candidates: list[PooledDevice]) -> PooledDevice | None`) and a registry `dict[str, AssignmentStrategy]` - [ ] 4.4 Implement the default `fifo_match` strategy: return the first candidate in `candidates` (oldest-synced-first is not required; caller already filters to idle+constraint-matching) or `None` if `candidates` is empty - [ ] 4.5 Implement `TaskScheduler(pool: DevicePool, store: CloudStore, config: CloudConfig)` with `submit(goal=None, workflow_definition_id=None, constraints=None) -> str` (returns task id), raising a clear error if `config.max_queue_depth` queued tasks already exist - [ ] 4.6 Implement `TaskScheduler.assign() -> list[Assignment]`: for each queued task (oldest first), filter `pool.list_devices()` to `idle` devices matching `constraints.driver_type`/`capability_tags`, call the configured `AssignmentStrategy`, and on a match transition the task to `assigned` recording `device_id`/`host_id`; leave unmatched tasks `queued` - [ ] 4.7 Raise a clear configuration error if `config.default_assignment_strategy` names a strategy not present in the registry - [ ] 4.8 Write unit tests: submission enqueues with status `queued`; queue-depth-limit rejection; assignment picks a matching idle device via `fifo_match`; assignment leaves a task queued when no device matches; two tasks queued in order are assigned in submission order when only one device is available; unregistered strategy name raises at configuration/first-assign time ## 5. TaskDispatcher composition (capability: task-scheduler) - [ ] 5.1 Implement `cloud/dispatch.py`'s `Assignment{task_id, device_id, host_id, goal, workflow_definition_id}` and `RemoteDispatchNotSupportedError` - [ ] 5.2 Implement `TaskDispatcher(local_host_id: str, task_runner_factory, workflow_runner_factory, store: CloudStore)` with `dispatch(assignment: Assignment) -> None` - [ ] 5.3 Implement the goal-based dispatch path: construct `core.models.Task(goal=assignment.goal, device_id=assignment.device_id)`, call the existing `runtime.task.TaskRunner(...).run(task)` (import only, no edits to `runtime/`), and update the `ScheduledTask`'s status to `done`/`failed` from `task.status`/`task.failure_reason` - [ ] 5.4 Implement the workflow-based dispatch path: load the referenced `WorkflowDefinition` and call the existing `workflow.runner.WorkflowRunner(...).run(definition, device_id=assignment.device_id)` (import only, no edits to `workflow/`), mapping the resulting `WorkflowRun.status` to the `ScheduledTask`'s status - [ ] 5.5 Implement the remote-assignment guard: if `assignment.host_id != local_host_id`, raise `RemoteDispatchNotSupportedError` before constructing any `Task`/`WorkflowDefinition`, leaving the `ScheduledTask` status unchanged at `assigned` - [ ] 5.6 Write unit tests: local goal-based dispatch runs a stubbed `TaskRunner` and updates status to `done`/`failed` correctly; local workflow-based dispatch runs a stubbed `WorkflowRunner` and updates status correctly; remote-host assignment raises `RemoteDispatchNotSupportedError` and leaves status as `assigned` ## 6. Plugin manifest and registry (capability: plugin-system) - [ ] 6.1 Implement `cloud/plugins.py`'s `PluginManifest{name, version, entry_point_kind: Literal["driver", "tool", "skill"], target}` with validation (all fields required, `entry_point_kind` restricted to the three literals) - [ ] 6.2 Add a `plugins` table + `CloudStore.save_plugin()`, `CloudStore.list_plugins()`, `CloudStore.get_plugin(name)` to `cloud/store.py`, storing `wired: bool` alongside each manifest - [ ] 6.3 Implement `PluginRegistry(store: CloudStore)` with `register(manifest: PluginManifest) -> PluginManifest`: reject unknown `entry_point_kind`, reject duplicate `name`, persist via `CloudStore.save_plugin()` - [ ] 6.4 Implement driver-kind wiring: resolve `manifest.target` (dotted `module:attribute` string) to a `DriverFactoryBuilder` callable via `importlib`, and call `driver_registry.register_driver_type(manifest.name, builder)` if importable; raise a clear, named error if the driver-registry function is not importable in the running environment - [ ] 6.5 Implement tool-/skill-kind handling: store the manifest with `wired=False` and do not attempt any further resolution or registration - [ ] 6.6 Implement `PluginRegistry.discover_entry_points() -> list[PluginManifest]` using `importlib.metadata.entry_points(group="device_agent_runtime.plugins")`, resolving each entry point and registering the resulting manifest - [ ] 6.7 Implement `PluginRegistry.discover_manifest_files(scan_path) -> list[PluginManifest]` globbing `plugin.json` under `scan_path`, parsing and registering each; on parse/validation failure, record the file path + error and continue (never abort the scan) - [ ] 6.8 Implement `PluginRegistry.discover() -> DiscoveryResult{registered: list[PluginManifest], errors: list[str]}` combining both discovery sources - [ ] 6.9 Write unit tests: valid manifest registers successfully; unrecognized `entry_point_kind` rejected; duplicate name rejected; driver-kind manifest registers into a fake `driver_registry.register_driver_type`; driver-kind manifest raises a named error when that function is not importable; tool-/skill-kind manifests register with `wired=False` and touch no other registry; entry-point discovery registers a fake installed plugin; manifest-file discovery registers a valid file and skips + records a malformed one without aborting ## 7. Platform SDK REST API (capability: platform-sdk) - [ ] 7.1 Implement `cloud/sdk/models.py`: Pydantic request/response models for task submission, task status, device listing, host listing, plugin listing, and plugin registration - [ ] 7.2 Implement `AuthProvider` protocol (`authenticate(request) -> Principal | None`) and `NullAuthProvider` (always returns an anonymous `Principal`) in `cloud/sdk/api.py` or a small `cloud/sdk/auth.py` - [ ] 7.3 Implement `create_cloud_router(*, pool: DevicePool, scheduler: TaskScheduler, plugin_registry: PluginRegistry, auth_provider: AuthProvider = NullAuthProvider(), version_prefix: str = "/v1") -> APIRouter` in `cloud/sdk/api.py`, mirroring `api/console.py`'s `create_console_router` shape - [ ] 7.4 Implement `POST {prefix}/tasks` (submit via `TaskScheduler.submit()`), `GET {prefix}/tasks/{task_id}` (status via `CloudStore.get_task()`, 404 on unknown id) - [ ] 7.5 Implement `GET {prefix}/devices` (via `DevicePool.list_devices()`) and `GET {prefix}/hosts` (via `DevicePool.list_hosts()`) - [ ] 7.6 Implement `GET {prefix}/plugins` (via `PluginRegistry`/`CloudStore.list_plugins()`) and `POST {prefix}/plugins` (via `PluginRegistry.register()`, returning a validation/conflict error response on failure) - [ ] 7.7 Wire every route through `auth_provider.authenticate(request)`, returning an authorization error response when it returns `None` - [ ] 7.8 Write unit tests using FastAPI's `TestClient`: submit-then-status round trip; unknown task id returns 404; device/host listing reflects pool state; plugin listing/registration round trip; a custom rejecting `AuthProvider` causes every route to return an authorization error while the default `NullAuthProvider` allows all of the above through unchanged ## 8. Python SDK client (capability: platform-sdk) - [ ] 8.1 Implement `cloud/sdk/client.py`'s `CloudClient(base_url, *, http_client=None)` using `httpx`, with `submit_task()`, `get_task_status()`, `list_devices()`, `list_hosts()`, `list_plugins()`, `register_plugin()` methods matching `cloud/sdk/api.py`'s routes - [ ] 8.2 Write unit tests for `CloudClient` against a live `TestClient`-backed instance of the router from task 7.3: submit + status round trip via the client returns the same result as calling the routes directly ## 9. Composition safety checks and full-suite validation - [ ] 9.1 Confirm no existing file under `driver/`/`core/`, `device/`, `runtime/`, `tools/`, `workflow/`, `agents/`, `storage/`, `api/console.py`, or `api/mcp.py` is modified by this change (composition via import only, per design.md's D1/D5) - [ ] 9.2 Write a test that runs a real (non-mocked, stub-driver-backed) `runtime.task.TaskRunner` instance inside `TaskDispatcher.dispatch()`'s goal-based path, guarding against silent drift in `agent-runtime`'s public `run(task) -> Task` contract this change composes over - [ ] 9.3 Run the full test suite (`pytest`) and confirm every existing test in `tests/` passes unmodified, with only new `tests/test_device_pool.py`, `tests/test_task_scheduler.py`, `tests/test_task_dispatcher.py`, `tests/test_plugin_registry.py`, `tests/test_cloud_sdk_api.py`, `tests/test_cloud_client.py`-style files added