From 2cd314b183f2209c53b50c46e90f2f949a2beced Mon Sep 17 00:00:00 2001
From: Jerry Yan <792602257@qq.com>
Date: Mon, 13 Jul 2026 22:21:12 +0800
Subject: [PATCH] feat(cloud): add targeted task governance foundation
---
apps/cloud-api/cloud_api/app.py | 8 +
apps/device-host-agent/host_agent/client.py | 20 ++
.../host_agent/cloud_planner_client.py | 18 +-
.../device-host-agent/host_agent/heartbeat.py | 6 +
.../device-host-agent/tests/test_heartbeat.py | 4 +-
cloud-console/src/App.vue | 7 +-
cloud-console/src/api.ts | 8 +
cloud-console/src/types.ts | 13 ++
cloud-console/src/views/TasksView.vue | 108 ++++++++-
.../cloud-console-governance/.openspec.yaml | 2 +
.../cloud-console-governance/design.md | 212 ++++++++++++++++++
.../cloud-console-governance/proposal.md | 79 +++++++
.../specs/agent-runtime/spec.md | 50 +++++
.../specs/cloud-ai-usage-governance/spec.md | 52 +++++
.../specs/cloud-console-ui/spec.md | 50 +++++
.../specs/cloud-governance/spec.md | 53 +++++
.../specs/cloud-planner-proxy/spec.md | 38 ++++
.../specs/host-agent-protocol/spec.md | 61 +++++
.../specs/host-scoped-task-submission/spec.md | 30 +++
.../specs/platform-sdk/spec.md | 84 +++++++
.../specs/task-scheduler/spec.md | 58 +++++
.../changes/cloud-console-governance/tasks.md | 113 ++++++++++
packages/cloud-platform/cloud/auth.py | 2 +
packages/cloud-platform/cloud/db_models.py | 26 +++
packages/cloud-platform/cloud/governance.py | 61 +++++
.../cloud-platform/cloud/internal_api/api.py | 68 ++++++
.../cloud/internal_api/models.py | 23 ++
.../versions/0004_cloud_governance.py | 56 +++++
packages/cloud-platform/cloud/repository.py | 25 +++
packages/cloud-platform/cloud/scheduler.py | 6 +
packages/cloud-platform/cloud/schema.py | 2 +-
packages/cloud-platform/cloud/sdk/api.py | 53 ++++-
packages/cloud-platform/cloud/sdk/client.py | 33 ++-
.../cloud/sdk/governance_api.py | 169 ++++++++++++++
packages/cloud-platform/cloud/sdk/models.py | 35 +++
.../cloud-platform/cloud/sql_repository.py | 144 ++++++++++++
runtime/tool_calling_client.py | 54 ++++-
tests/test_cloud_governance.py | 140 ++++++++++++
tests/test_cloud_sdk_api.py | 64 ++++++
tests/test_host_agent_internal_api.py | 40 +++-
tests/test_task_scheduler.py | 39 ++++
41 files changed, 2099 insertions(+), 15 deletions(-)
create mode 100644 openspec/changes/cloud-console-governance/.openspec.yaml
create mode 100644 openspec/changes/cloud-console-governance/design.md
create mode 100644 openspec/changes/cloud-console-governance/proposal.md
create mode 100644 openspec/changes/cloud-console-governance/specs/agent-runtime/spec.md
create mode 100644 openspec/changes/cloud-console-governance/specs/cloud-ai-usage-governance/spec.md
create mode 100644 openspec/changes/cloud-console-governance/specs/cloud-console-ui/spec.md
create mode 100644 openspec/changes/cloud-console-governance/specs/cloud-governance/spec.md
create mode 100644 openspec/changes/cloud-console-governance/specs/cloud-planner-proxy/spec.md
create mode 100644 openspec/changes/cloud-console-governance/specs/host-agent-protocol/spec.md
create mode 100644 openspec/changes/cloud-console-governance/specs/host-scoped-task-submission/spec.md
create mode 100644 openspec/changes/cloud-console-governance/specs/platform-sdk/spec.md
create mode 100644 openspec/changes/cloud-console-governance/specs/task-scheduler/spec.md
create mode 100644 openspec/changes/cloud-console-governance/tasks.md
create mode 100644 packages/cloud-platform/cloud/governance.py
create mode 100644 packages/cloud-platform/cloud/migrations/versions/0004_cloud_governance.py
create mode 100644 packages/cloud-platform/cloud/sdk/governance_api.py
create mode 100644 tests/test_cloud_governance.py
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 @@