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 @@