diff --git a/apps/device-host-agent/host_agent/client.py b/apps/device-host-agent/host_agent/client.py index 209b641..2214c80 100644 --- a/apps/device-host-agent/host_agent/client.py +++ b/apps/device-host-agent/host_agent/client.py @@ -157,6 +157,7 @@ class HostAgentClient: "address": address, "devices": [device.model_dump(mode="json") for device in devices], "policy_revision": policy_revision, + "planner_transport": self.config.ai_planner_transport, }, ) return HeartbeatResponse.model_validate(response.json()) diff --git a/cloud-console/src/types.ts b/cloud-console/src/types.ts index 5eea4aa..a2be7bd 100644 --- a/cloud-console/src/types.ts +++ b/cloud-console/src/types.ts @@ -63,6 +63,7 @@ export interface HostRecord { host_id: string; address: string | null; last_seen_at: string; + planner_transport: "direct" | "cloud"; } export type PluginEntryPointKind = "driver" | "tool" | "skill"; diff --git a/cloud-console/src/views/UsersView.vue b/cloud-console/src/views/UsersView.vue index 82a8be1..355da3d 100644 --- a/cloud-console/src/views/UsersView.vue +++ b/cloud-console/src/views/UsersView.vue @@ -61,6 +61,9 @@ const hostUsageEvents = ref([]); const selectedUser = computed( () => users.value.find((user) => user.id === selectedUserId.value) ?? null, ); +const selectedHost = computed( + () => hosts.value.find((host) => host.host_id === selectedHostId.value) ?? null, +); const deviceKey = (device: Pick) => `${device.host_id}\u0000${device.device_id}`; @@ -386,7 +389,10 @@ onMounted(() => void refresh());
-

+

+ Direct provider transport: unmetered. Cloud cannot enforce or verify this Host's token budget. +

+

{{ hostUsage.usage_day }}: used {{ hostUsage.used_tokens }}, reserved {{ hostUsage.reserved_tokens }}, remaining {{ hostUsage.remaining_tokens ?? "unmetered" }} tokens.

diff --git a/openspec/changes/cloud-console-governance/tasks.md b/openspec/changes/cloud-console-governance/tasks.md index 350abdc..21cbf1b 100644 --- a/openspec/changes/cloud-console-governance/tasks.md +++ b/openspec/changes/cloud-console-governance/tasks.md @@ -40,7 +40,7 @@ - [x] 3.3 Add `governance:read` and `governance:admin` scopes and policy-aware public task authorization that combines `tasks:submit` with the authenticated human user's effective submission policy. -- [ ] 3.4 Add bounded, non-secret public governance routes and `CloudClient` +- [x] 3.4 Add bounded, non-secret public governance routes and `CloudClient` methods for user policy, Host policy, Host AI-budget summaries, and paginated usage events; audit every policy mutation. - [ ] 3.5 Add public API/SDK tests for targeted submission, target-policy @@ -74,7 +74,7 @@ - [x] 5.3 Add Cloud proxy preflight reservation, configured conservative per-call ceiling, provider invocation, actual-usage settlement, and bounded unknown-usage reservation expiry. -- [ ] 5.4 Record non-secret usage events and expose accurate +- [x] 5.4 Record non-secret usage events and expose accurate used/reserved/remaining UTC-day budget summaries; explicitly report direct transport as unmetered. - [ ] 5.5 Add provider-fake, repository concurrency, Cloud API, and Host @@ -89,7 +89,7 @@ validation errors. - [x] 6.2 Complete or reconcile the admin Users view, then add user- submission-policy editing with safe refresh and conflict/error handling. -- [ ] 6.3 Add Host policy administration and AI-usage/budget views, including +- [x] 6.3 Add Host policy administration and AI-usage/budget views, including revision display, unmetered direct Hosts, and no rendering of prompts, screenshots, provider credentials, cookies, or lease secrets. - [ ] 6.4 Add frontend tests for task composer scope/policy failures, diff --git a/packages/cloud-platform/cloud/db_models.py b/packages/cloud-platform/cloud/db_models.py index ea9703c..1f84f27 100644 --- a/packages/cloud-platform/cloud/db_models.py +++ b/packages/cloud-platform/cloud/db_models.py @@ -51,6 +51,9 @@ class HostRow(Base): display_name: Mapped[str | None] = mapped_column(String, nullable=True) enrolled_at: Mapped[str | None] = mapped_column(String, nullable=True) revoked_at: Mapped[str | None] = mapped_column(String, nullable=True) + planner_transport: Mapped[str] = mapped_column( + String, nullable=False, default="direct", server_default=text("'direct'") + ) class DeviceEnrollmentRow(Base): diff --git a/packages/cloud-platform/cloud/internal_api/api.py b/packages/cloud-platform/cloud/internal_api/api.py index 9375414..f9bf374 100644 --- a/packages/cloud-platform/cloud/internal_api/api.py +++ b/packages/cloud-platform/cloud/internal_api/api.py @@ -177,6 +177,7 @@ def create_internal_router( devices, address=payload.address, allow_device_takeover=allow_device_takeover, + planner_transport=payload.planner_transport, ) policy = pool.store.get_host_governance_policy(host_id) policy_revision = policy.revision if policy is not None else 0 diff --git a/packages/cloud-platform/cloud/internal_api/models.py b/packages/cloud-platform/cloud/internal_api/models.py index 48dfa3a..cd4964a 100644 --- a/packages/cloud-platform/cloud/internal_api/models.py +++ b/packages/cloud-platform/cloud/internal_api/models.py @@ -39,6 +39,7 @@ class HeartbeatRequest(BaseModel): address: str | None = None devices: list[DeviceSnapshotModel] = Field(default_factory=list) policy_revision: int = Field(default=0, ge=0) + planner_transport: Literal["direct", "cloud"] = "direct" class HostGovernancePolicyModel(BaseModel): diff --git a/packages/cloud-platform/cloud/migrations/versions/0006_host_planner_transport.py b/packages/cloud-platform/cloud/migrations/versions/0006_host_planner_transport.py new file mode 100644 index 0000000..c65e912 --- /dev/null +++ b/packages/cloud-platform/cloud/migrations/versions/0006_host_planner_transport.py @@ -0,0 +1,23 @@ +"""Record each Host's planner transport for governance visibility.""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0006_host_planner_transport" +down_revision = "0005_cloud_token_usage" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "host_registrations", + sa.Column("planner_transport", sa.String(), nullable=False, server_default="direct"), + ) + + +def downgrade() -> None: + op.drop_column("host_registrations", "planner_transport") diff --git a/packages/cloud-platform/cloud/pool.py b/packages/cloud-platform/cloud/pool.py index 54aaabd..dfac7c4 100644 --- a/packages/cloud-platform/cloud/pool.py +++ b/packages/cloud-platform/cloud/pool.py @@ -34,6 +34,7 @@ class HostRegistration: host_id: str address: str | None last_seen_at: datetime + planner_transport: Literal["direct", "cloud"] = "direct" @dataclass(frozen=True) @@ -61,6 +62,7 @@ class DevicePool: snapshot: list[Device], *, address: str | None = None, + planner_transport: Literal["direct", "cloud"] = "direct", allow_device_takeover: bool = False, ) -> None: """Push a host's current device snapshot into the pool. @@ -70,7 +72,12 @@ class DevicePool: other hosts are untouched. """ now = utc_now() - self.store.upsert_host(host_id, address=address, last_seen_at=now) + self.store.upsert_host( + host_id, + address=address, + last_seen_at=now, + planner_transport=planner_transport, + ) devices = [self._to_pooled(device, host_id, now) for device in snapshot] if allow_device_takeover: self.store.replace_host_devices( diff --git a/packages/cloud-platform/cloud/repository.py b/packages/cloud-platform/cloud/repository.py index d62aab7..bcaf193 100644 --- a/packages/cloud-platform/cloud/repository.py +++ b/packages/cloud-platform/cloud/repository.py @@ -154,6 +154,7 @@ class CloudRepository(Protocol): *, address: str | None, last_seen_at: datetime, + planner_transport: Literal["direct", "cloud"] = "direct", ) -> None: ... def replace_host_devices( diff --git a/packages/cloud-platform/cloud/schema.py b/packages/cloud-platform/cloud/schema.py index a6f0914..2c6c3e2 100644 --- a/packages/cloud-platform/cloud/schema.py +++ b/packages/cloud-platform/cloud/schema.py @@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext from cloud.database import create_database_engine, normalize_database_url -HEAD_REVISION = "0005_cloud_token_usage" +HEAD_REVISION = "0006_host_planner_transport" class SchemaVersionError(RuntimeError): diff --git a/packages/cloud-platform/cloud/sdk/api.py b/packages/cloud-platform/cloud/sdk/api.py index 8078895..6e7d717 100644 --- a/packages/cloud-platform/cloud/sdk/api.py +++ b/packages/cloud-platform/cloud/sdk/api.py @@ -240,6 +240,7 @@ def create_cloud_router( host_id=h.host_id, address=h.address, last_seen_at=h.last_seen_at.isoformat() if h.last_seen_at else "", + planner_transport=h.planner_transport, ) for h in pool.list_hosts() ] diff --git a/packages/cloud-platform/cloud/sdk/models.py b/packages/cloud-platform/cloud/sdk/models.py index e98612a..e7e16bd 100644 --- a/packages/cloud-platform/cloud/sdk/models.py +++ b/packages/cloud-platform/cloud/sdk/models.py @@ -86,6 +86,7 @@ class HostResponse(BaseModel): host_id: str address: str | None = None last_seen_at: str + planner_transport: Literal["direct", "cloud"] = "direct" class PluginRegistrationRequest(BaseModel): diff --git a/packages/cloud-platform/cloud/sql_repository.py b/packages/cloud-platform/cloud/sql_repository.py index be739cd..875bb0f 100644 --- a/packages/cloud-platform/cloud/sql_repository.py +++ b/packages/cloud-platform/cloud/sql_repository.py @@ -266,6 +266,7 @@ class SQLAlchemyCloudRepository: *, address: str | None, last_seen_at: datetime, + planner_transport: str = "direct", ) -> None: with self._sessions.begin() as session: row = session.get(HostRow, host_id) @@ -275,12 +276,14 @@ class SQLAlchemyCloudRepository: host_id=host_id, address=address, last_seen_at=_iso(last_seen_at), + planner_transport=planner_transport, ) ) return if address is not None: row.address = address row.last_seen_at = _iso(last_seen_at) + row.planner_transport = planner_transport def replace_host_devices( self, @@ -1419,6 +1422,9 @@ def _host_from_row(row: HostRow) -> Any: host_id=row.host_id, address=row.address, last_seen_at=_parse_dt(row.last_seen_at) or utc_now(), + planner_transport=( + row.planner_transport if row.planner_transport in {"direct", "cloud"} else "direct" + ), ) diff --git a/tests/test_host_agent_internal_api.py b/tests/test_host_agent_internal_api.py index 6ce2ce0..77a413a 100644 --- a/tests/test_host_agent_internal_api.py +++ b/tests/test_host_agent_internal_api.py @@ -91,6 +91,19 @@ def test_authenticated_heartbeat_replaces_complete_snapshot(tmp_path) -> None: } +def test_heartbeat_records_host_planner_transport(tmp_path) -> None: + client, pool = _build_client(tmp_path) + + response = client.put( + "/internal/v1/hosts/host-a/heartbeat", + headers={"Authorization": "Bearer token-a"}, + json={**_heartbeat_payload("host-a", "device-a"), "planner_transport": "cloud"}, + ) + + assert response.status_code == 200 + assert pool.store.get_host("host-a").planner_transport == "cloud" # type: ignore[union-attr] + + def test_empty_heartbeat_removes_only_reporting_hosts_devices(tmp_path) -> None: client, pool = _build_client(tmp_path) client.put(