12 KiB
12 KiB
1. Package scaffolding
- 1.1 Create the
cloud/package (__init__.py,pool.py,scheduler.py,dispatch.py,plugins.py,store.py,config.py) and thecloud/sdk/sub-package (__init__.py,api.py,client.py,models.py) - 1.2 Add
cloud*to[tool.setuptools.packages.find].includeinpyproject.toml - 1.3 Promote
httpxfrom[dependency-groups].devto[project].dependenciesinpyproject.toml(needed at runtime bycloud/sdk/client.py) - 1.4 Implement
cloud/config.py:CloudConfigdataclass withsync_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
cloudandcloud.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'sCloudStore(db_path)with schema creation forhost_registrationsandpooled_devicestables (connect-per-callsqlite3pattern, followingstorage/task_metadata.py/workflow/store.py) - 2.3 Implement
CloudStore.upsert_host(host_id, address, last_seen_at)andCloudStore.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)withsync_host_devices(host_id, snapshot: list[core.models.Device]), converting eachDeviceinto aPooledDeviceand callingCloudStore.upsert_host()/replace_host_devices() - 3.2 Implement
DevicePool.list_devices() -> list[PooledDevice]andDevicePool.get_device(device_id) -> PooledDevice | None, both computing per-host staleness lazily at call time (now - last_seen_at > config.stale_after_secondsimplies statusunreachable, 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+PooledDevices; re-sync updates last-seen and replaces devices; a host whose last-seen exceeds the staleness threshold reports all its devicesunreachableon the nextlist_devices()/get_device()call; a host that resyncs after being stale immediately stops being reported unreachable; lookup for an unknowndevice_idreturnsNone; 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_taskstable +CloudStore.enqueue_task(),CloudStore.list_queued_tasks(),CloudStore.update_task(),CloudStore.get_task(task_id)tocloud/store.py - 4.3 Implement
AssignmentStrategyprotocol/ABC (select(task, candidates: list[PooledDevice]) -> PooledDevice | None) and a registrydict[str, AssignmentStrategy] - 4.4 Implement the default
fifo_matchstrategy: return the first candidate incandidates(oldest-synced-first is not required; caller already filters to idle+constraint-matching) orNoneifcandidatesis empty - 4.5 Implement
TaskScheduler(pool: DevicePool, store: CloudStore, config: CloudConfig)withsubmit(goal=None, workflow_definition_id=None, constraints=None) -> str(returns task id), raising a clear error ifconfig.max_queue_depthqueued tasks already exist - 4.6 Implement
TaskScheduler.assign() -> list[Assignment]: for each queued task (oldest first), filterpool.list_devices()toidledevices matchingconstraints.driver_type/capability_tags, call the configuredAssignmentStrategy, and on a match transition the task toassignedrecordingdevice_id/host_id; leave unmatched tasksqueued - 4.7 Raise a clear configuration error if
config.default_assignment_strategynames 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 viafifo_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'sAssignment{task_id, device_id, host_id, goal, workflow_definition_id}andRemoteDispatchNotSupportedError - 5.2 Implement
TaskDispatcher(local_host_id: str, task_runner_factory, workflow_runner_factory, store: CloudStore)withdispatch(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 existingruntime.task.TaskRunner(...).run(task)(import only, no edits toruntime/), and update theScheduledTask's status todone/failedfromtask.status/task.failure_reason - 5.4 Implement the workflow-based dispatch path: load the referenced
WorkflowDefinitionand call the existingworkflow.runner.WorkflowRunner(...).run(definition, device_id=assignment.device_id)(import only, no edits toworkflow/), mapping the resultingWorkflowRun.statusto theScheduledTask's status - 5.5 Implement the remote-assignment guard: if
assignment.host_id != local_host_id, raiseRemoteDispatchNotSupportedErrorbefore constructing anyTask/WorkflowDefinition, leaving theScheduledTaskstatus unchanged atassigned - 5.6 Write unit tests: local goal-based dispatch runs a stubbed
TaskRunnerand updates status todone/failedcorrectly; local workflow-based dispatch runs a stubbedWorkflowRunnerand updates status correctly; remote-host assignment raisesRemoteDispatchNotSupportedErrorand leaves status asassigned
6. Plugin manifest and registry (capability: plugin-system)
- 6.1 Implement
cloud/plugins.py'sPluginManifest{name, version, entry_point_kind: Literal["driver", "tool", "skill"], target}with validation (all fields required,entry_point_kindrestricted to the three literals) - 6.2 Add a
pluginstable +CloudStore.save_plugin(),CloudStore.list_plugins(),CloudStore.get_plugin(name)tocloud/store.py, storingwired: boolalongside each manifest - 6.3 Implement
PluginRegistry(store: CloudStore)withregister(manifest: PluginManifest) -> PluginManifest: reject unknownentry_point_kind, reject duplicatename, persist viaCloudStore.save_plugin() - 6.4 Implement driver-kind wiring: resolve
manifest.target(dottedmodule:attributestring) to aDriverFactoryBuildercallable viaimportlib, and calldriver_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=Falseand do not attempt any further resolution or registration - 6.6 Implement
PluginRegistry.discover_entry_points() -> list[PluginManifest]usingimportlib.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]globbingplugin.jsonunderscan_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_kindrejected; duplicate name rejected; driver-kind manifest registers into a fakedriver_registry.register_driver_type; driver-kind manifest raises a named error when that function is not importable; tool-/skill-kind manifests register withwired=Falseand 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
AuthProviderprotocol (authenticate(request) -> Principal | None) andNullAuthProvider(always returns an anonymousPrincipal) incloud/sdk/api.pyor a smallcloud/sdk/auth.py - 7.3 Implement
create_cloud_router(*, pool: DevicePool, scheduler: TaskScheduler, plugin_registry: PluginRegistry, auth_provider: AuthProvider = NullAuthProvider(), version_prefix: str = "/v1") -> APIRouterincloud/sdk/api.py, mirroringapi/console.py'screate_console_routershape - 7.4 Implement
POST {prefix}/tasks(submit viaTaskScheduler.submit()),GET {prefix}/tasks/{task_id}(status viaCloudStore.get_task(), 404 on unknown id) - 7.5 Implement
GET {prefix}/devices(viaDevicePool.list_devices()) andGET {prefix}/hosts(viaDevicePool.list_hosts()) - 7.6 Implement
GET {prefix}/plugins(viaPluginRegistry/CloudStore.list_plugins()) andPOST {prefix}/plugins(viaPluginRegistry.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 returnsNone - 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 rejectingAuthProvidercauses every route to return an authorization error while the defaultNullAuthProviderallows all of the above through unchanged
8. Python SDK client (capability: platform-sdk)
- 8.1 Implement
cloud/sdk/client.py'sCloudClient(base_url, *, http_client=None)usinghttpx, withsubmit_task(),get_task_status(),list_devices(),list_hosts(),list_plugins(),register_plugin()methods matchingcloud/sdk/api.py's routes - 8.2 Write unit tests for
CloudClientagainst a liveTestClient-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, orapi/mcp.pyis 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.TaskRunnerinstance insideTaskDispatcher.dispatch()'s goal-based path, guarding against silent drift inagent-runtime's publicrun(task) -> Taskcontract this change composes over - 9.3 Run the full test suite (
pytest) and confirm every existing test intests/passes unmodified, with only newtests/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