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