diff --git a/apps/cloud-api/cloud_api/app.py b/apps/cloud-api/cloud_api/app.py index 4499d10..5eca5f2 100644 --- a/apps/cloud-api/cloud_api/app.py +++ b/apps/cloud-api/cloud_api/app.py @@ -43,6 +43,7 @@ from cloud.pool import DevicePool from cloud.scheduler import TaskScheduler from cloud.schema import require_current_schema from cloud.sdk.api import create_cloud_router +from cloud.sdk.governance_api import create_governance_router from cloud.sdk.user_api import create_user_auth_router from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings from core.models import utc_now @@ -288,11 +289,18 @@ def create_app( config=control_config, ) ) + app.include_router( + create_governance_router( + repository=repository, + auth_provider=auth_provider, + ) + ) app.include_router( create_internal_router( pool=pool, auth_provider=auth_provider, lease_duration_seconds=control_config.lease_duration_seconds, + scheduler=scheduler, ) ) diff --git a/apps/device-host-agent/host_agent/client.py b/apps/device-host-agent/host_agent/client.py index 77e5a46..209b641 100644 --- a/apps/device-host-agent/host_agent/client.py +++ b/apps/device-host-agent/host_agent/client.py @@ -14,6 +14,7 @@ from cloud.internal_api.models import ( DeviceSnapshotModel, HeartbeatResponse, HostEnrollmentResponse, + HostTaskSubmissionResponse, LeaseRenewalResponse, TerminalResultResponse, ) @@ -146,6 +147,7 @@ class HostAgentClient: devices: list[DeviceSnapshotModel], *, address: str | None = None, + policy_revision: int = 0, ) -> HeartbeatResponse: response = await self._request( "PUT", @@ -154,10 +156,28 @@ class HostAgentClient: "host_id": self.config.host_id, "address": address, "devices": [device.model_dump(mode="json") for device in devices], + "policy_revision": policy_revision, }, ) return HeartbeatResponse.model_validate(response.json()) + async def submit_self_task( + self, + *, + goal: str, + device_id: str | None = None, + ) -> HostTaskSubmissionResponse: + response = await self._request( + "POST", + f"/internal/v1/hosts/{self.config.host_id}/tasks", + json={ + "host_id": self.config.host_id, + "goal": goal, + "device_id": device_id, + }, + ) + return HostTaskSubmissionResponse.model_validate(response.json()) + async def claim(self) -> AssignmentModel | None: response = await self._request( "POST", diff --git a/apps/device-host-agent/host_agent/cloud_planner_client.py b/apps/device-host-agent/host_agent/cloud_planner_client.py index 7b8f77f..04e449a 100644 --- a/apps/device-host-agent/host_agent/cloud_planner_client.py +++ b/apps/device-host-agent/host_agent/cloud_planner_client.py @@ -27,7 +27,7 @@ import httpx from cloud.internal_api.models import PlannerDecisionError, PlannerDecisionResponse from host_agent.config import HostAgentConfig -from runtime.tool_calling_client import ToolCallDecision, ToolCallUnavailable +from runtime.tool_calling_client import ToolCallDecision, ToolCallUnavailable, ToolCallUsage from runtime.tool_specs import ToolSpec @@ -85,6 +85,22 @@ class CloudProxyToolCallingClient: return ToolCallDecision( tool_name=decoded.tool_name, arguments=dict(decoded.arguments), + usage=( + ToolCallUsage( + input_tokens=decoded.input_tokens, + output_tokens=decoded.output_tokens, + total_tokens=decoded.total_tokens, + ) + if any( + value is not None + for value in ( + decoded.input_tokens, + decoded.output_tokens, + decoded.total_tokens, + ) + ) + else None + ), ) raise ToolCallUnavailable(_error_detail(response)) diff --git a/apps/device-host-agent/host_agent/heartbeat.py b/apps/device-host-agent/host_agent/heartbeat.py index 1fadf7b..25638ff 100644 --- a/apps/device-host-agent/host_agent/heartbeat.py +++ b/apps/device-host-agent/host_agent/heartbeat.py @@ -45,13 +45,19 @@ class HeartbeatSynchronizer: self._sleep = sleep self.status_tracker = status_tracker self.on_sync = on_sync + self.policy_revision = 0 + self.policy = None async def sync_once(self) -> HeartbeatResponse: snapshot = build_device_snapshot(self.manager) response = await self.client.heartbeat( snapshot, address=self.address, + policy_revision=self.policy_revision, ) + self.policy_revision = response.policy_revision + if response.policy is not None: + self.policy = response.policy if self.status_tracker is not None: self.status_tracker.mark_heartbeat(ok=True, device_count=len(snapshot)) if self.on_sync is not None: diff --git a/apps/device-host-agent/tests/test_heartbeat.py b/apps/device-host-agent/tests/test_heartbeat.py index 7b42aa3..2a478b5 100644 --- a/apps/device-host-agent/tests/test_heartbeat.py +++ b/apps/device-host-agent/tests/test_heartbeat.py @@ -58,7 +58,7 @@ def test_heartbeat_synchronizer_runs_at_configured_interval_until_stopped() -> N calls: list[list[str]] = [] class FakeClient: - async def heartbeat(self, devices, *, address=None): + async def heartbeat(self, devices, *, address=None, policy_revision=0): calls.append([device.device_id for device in devices]) return HeartbeatResponse( host_id="host-a", @@ -96,7 +96,7 @@ def test_sync_once_notifies_status_tracker_and_on_sync_with_device_count() -> No ) class FakeClient: - async def heartbeat(self, devices, *, address=None): + async def heartbeat(self, devices, *, address=None, policy_revision=0): return HeartbeatResponse( host_id="host-a", accepted_devices=len(devices), diff --git a/cloud-console/src/App.vue b/cloud-console/src/App.vue index da4fe45..7846129 100644 --- a/cloud-console/src/App.vue +++ b/cloud-console/src/App.vue @@ -32,6 +32,11 @@ const canAdminPlugins = computed( currentUser.value?.scopes.includes("*") || currentUser.value?.scopes.includes("plugins:admin"), ); +const canSubmitTasks = computed( + () => + currentUser.value?.scopes.includes("*") || + currentUser.value?.scopes.includes("tasks:submit"), +); const isAuthenticated = computed(() => currentUser.value !== null); const currentUserLabel = computed(() => currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "", @@ -128,7 +133,7 @@ const activeComponent = computed(() => {
- +
diff --git a/cloud-console/src/api.ts b/cloud-console/src/api.ts index 52b1a70..12cf667 100644 --- a/cloud-console/src/api.ts +++ b/cloud-console/src/api.ts @@ -6,6 +6,7 @@ import type { PluginRegistrationPayload, TaskAttempt, TaskListResponse, + TaskSubmissionPayload, TaskStatus, } from "./types"; @@ -135,6 +136,13 @@ export function getTaskAttempts(taskId: string): Promise { return request(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`); } +export function submitTask(payload: TaskSubmissionPayload): Promise<{ task_id: string }> { + return request<{ task_id: string }>("/v1/tasks", { + method: "POST", + body: JSON.stringify(payload), + }); +} + export function listDevices(): Promise { return request("/v1/devices"); } diff --git a/cloud-console/src/types.ts b/cloud-console/src/types.ts index b25ea4d..2f9d684 100644 --- a/cloud-console/src/types.ts +++ b/cloud-console/src/types.ts @@ -14,9 +14,22 @@ export interface TaskListItem { assigned_host_id: string | null; attempt_count: number; failure_reason: string | null; + target_host_id: string | null; + target_device_id: string | null; created_at: string; } +export interface TaskSubmissionPayload { + goal?: string; + workflow_definition_id?: string; + constraints?: { + driver_type?: string; + capability_tags?: string[]; + target_host_id?: string; + target_device_id?: string; + }; +} + export interface TaskListResponse { items: TaskListItem[]; total: number; diff --git a/cloud-console/src/views/TasksView.vue b/cloud-console/src/views/TasksView.vue index 2f7c626..c2adf27 100644 --- a/cloud-console/src/views/TasksView.vue +++ b/cloud-console/src/views/TasksView.vue @@ -1,18 +1,25 @@