feat(cloud): report planner transport status

This commit is contained in:
2026-07-13 23:15:46 +08:00
parent 40efa53411
commit 5b64efab53
15 changed files with 71 additions and 6 deletions
@@ -157,6 +157,7 @@ class HostAgentClient:
"address": address, "address": address,
"devices": [device.model_dump(mode="json") for device in devices], "devices": [device.model_dump(mode="json") for device in devices],
"policy_revision": policy_revision, "policy_revision": policy_revision,
"planner_transport": self.config.ai_planner_transport,
}, },
) )
return HeartbeatResponse.model_validate(response.json()) return HeartbeatResponse.model_validate(response.json())
+1
View File
@@ -63,6 +63,7 @@ export interface HostRecord {
host_id: string; host_id: string;
address: string | null; address: string | null;
last_seen_at: string; last_seen_at: string;
planner_transport: "direct" | "cloud";
} }
export type PluginEntryPointKind = "driver" | "tool" | "skill"; export type PluginEntryPointKind = "driver" | "tool" | "skill";
+7 -1
View File
@@ -61,6 +61,9 @@ const hostUsageEvents = ref<TokenUsageEvent[]>([]);
const selectedUser = computed( const selectedUser = computed(
() => users.value.find((user) => user.id === selectedUserId.value) ?? null, () => 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<DeviceRecord, "host_id" | "device_id">) => const deviceKey = (device: Pick<DeviceRecord, "host_id" | "device_id">) =>
`${device.host_id}\u0000${device.device_id}`; `${device.host_id}\u0000${device.device_id}`;
@@ -386,7 +389,10 @@ onMounted(() => void refresh());
<label>Daily Cloud-proxy token budget <input v-model="hostDailyTokenBudget" inputmode="numeric" placeholder="Unlimited" /></label> <label>Daily Cloud-proxy token budget <input v-model="hostDailyTokenBudget" inputmode="numeric" placeholder="Unlimited" /></label>
</div> </div>
<div class="actions"><button class="primary" :disabled="saving || !selectedHostId" @click="saveHostPolicy">Save Host policy {{ hostPolicyRevision === null ? "" : `(revision ${hostPolicyRevision})` }}</button></div> <div class="actions"><button class="primary" :disabled="saving || !selectedHostId" @click="saveHostPolicy">Save Host policy {{ hostPolicyRevision === null ? "" : `(revision ${hostPolicyRevision})` }}</button></div>
<p v-if="hostUsage" class="muted"> <p v-if="selectedHost?.planner_transport === 'direct'" class="muted">
Direct provider transport: unmetered. Cloud cannot enforce or verify this Host's token budget.
</p>
<p v-else-if="hostUsage" class="muted">
{{ hostUsage.usage_day }}: used {{ hostUsage.used_tokens }}, reserved {{ hostUsage.reserved_tokens }}, {{ hostUsage.usage_day }}: used {{ hostUsage.used_tokens }}, reserved {{ hostUsage.reserved_tokens }},
remaining {{ hostUsage.remaining_tokens ?? "unmetered" }} tokens. remaining {{ hostUsage.remaining_tokens ?? "unmetered" }} tokens.
</p> </p>
@@ -40,7 +40,7 @@
- [x] 3.3 Add `governance:read` and `governance:admin` scopes and policy-aware - [x] 3.3 Add `governance:read` and `governance:admin` scopes and policy-aware
public task authorization that combines `tasks:submit` with the public task authorization that combines `tasks:submit` with the
authenticated human user's effective submission policy. 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 methods for user policy, Host policy, Host AI-budget summaries, and
paginated usage events; audit every policy mutation. paginated usage events; audit every policy mutation.
- [ ] 3.5 Add public API/SDK tests for targeted submission, target-policy - [ ] 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 - [x] 5.3 Add Cloud proxy preflight reservation, configured conservative
per-call ceiling, provider invocation, actual-usage settlement, and per-call ceiling, provider invocation, actual-usage settlement, and
bounded unknown-usage reservation expiry. 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 used/reserved/remaining UTC-day budget summaries; explicitly report
direct transport as unmetered. direct transport as unmetered.
- [ ] 5.5 Add provider-fake, repository concurrency, Cloud API, and Host - [ ] 5.5 Add provider-fake, repository concurrency, Cloud API, and Host
@@ -89,7 +89,7 @@
validation errors. validation errors.
- [x] 6.2 Complete or reconcile the admin Users view, then add user- - [x] 6.2 Complete or reconcile the admin Users view, then add user-
submission-policy editing with safe refresh and conflict/error handling. 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, revision display, unmetered direct Hosts, and no rendering of prompts,
screenshots, provider credentials, cookies, or lease secrets. screenshots, provider credentials, cookies, or lease secrets.
- [ ] 6.4 Add frontend tests for task composer scope/policy failures, - [ ] 6.4 Add frontend tests for task composer scope/policy failures,
@@ -51,6 +51,9 @@ class HostRow(Base):
display_name: Mapped[str | None] = mapped_column(String, nullable=True) display_name: Mapped[str | None] = mapped_column(String, nullable=True)
enrolled_at: 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) 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): class DeviceEnrollmentRow(Base):
@@ -177,6 +177,7 @@ def create_internal_router(
devices, devices,
address=payload.address, address=payload.address,
allow_device_takeover=allow_device_takeover, allow_device_takeover=allow_device_takeover,
planner_transport=payload.planner_transport,
) )
policy = pool.store.get_host_governance_policy(host_id) policy = pool.store.get_host_governance_policy(host_id)
policy_revision = policy.revision if policy is not None else 0 policy_revision = policy.revision if policy is not None else 0
@@ -39,6 +39,7 @@ class HeartbeatRequest(BaseModel):
address: str | None = None address: str | None = None
devices: list[DeviceSnapshotModel] = Field(default_factory=list) devices: list[DeviceSnapshotModel] = Field(default_factory=list)
policy_revision: int = Field(default=0, ge=0) policy_revision: int = Field(default=0, ge=0)
planner_transport: Literal["direct", "cloud"] = "direct"
class HostGovernancePolicyModel(BaseModel): class HostGovernancePolicyModel(BaseModel):
@@ -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")
+8 -1
View File
@@ -34,6 +34,7 @@ class HostRegistration:
host_id: str host_id: str
address: str | None address: str | None
last_seen_at: datetime last_seen_at: datetime
planner_transport: Literal["direct", "cloud"] = "direct"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -61,6 +62,7 @@ class DevicePool:
snapshot: list[Device], snapshot: list[Device],
*, *,
address: str | None = None, address: str | None = None,
planner_transport: Literal["direct", "cloud"] = "direct",
allow_device_takeover: bool = False, allow_device_takeover: bool = False,
) -> None: ) -> None:
"""Push a host's current device snapshot into the pool. """Push a host's current device snapshot into the pool.
@@ -70,7 +72,12 @@ class DevicePool:
other hosts are untouched. other hosts are untouched.
""" """
now = utc_now() 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] devices = [self._to_pooled(device, host_id, now) for device in snapshot]
if allow_device_takeover: if allow_device_takeover:
self.store.replace_host_devices( self.store.replace_host_devices(
@@ -154,6 +154,7 @@ class CloudRepository(Protocol):
*, *,
address: str | None, address: str | None,
last_seen_at: datetime, last_seen_at: datetime,
planner_transport: Literal["direct", "cloud"] = "direct",
) -> None: ... ) -> None: ...
def replace_host_devices( def replace_host_devices(
+1 -1
View File
@@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext
from cloud.database import create_database_engine, normalize_database_url 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): class SchemaVersionError(RuntimeError):
+1
View File
@@ -240,6 +240,7 @@ def create_cloud_router(
host_id=h.host_id, host_id=h.host_id,
address=h.address, address=h.address,
last_seen_at=h.last_seen_at.isoformat() if h.last_seen_at else "", 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() for h in pool.list_hosts()
] ]
@@ -86,6 +86,7 @@ class HostResponse(BaseModel):
host_id: str host_id: str
address: str | None = None address: str | None = None
last_seen_at: str last_seen_at: str
planner_transport: Literal["direct", "cloud"] = "direct"
class PluginRegistrationRequest(BaseModel): class PluginRegistrationRequest(BaseModel):
@@ -266,6 +266,7 @@ class SQLAlchemyCloudRepository:
*, *,
address: str | None, address: str | None,
last_seen_at: datetime, last_seen_at: datetime,
planner_transport: str = "direct",
) -> None: ) -> None:
with self._sessions.begin() as session: with self._sessions.begin() as session:
row = session.get(HostRow, host_id) row = session.get(HostRow, host_id)
@@ -275,12 +276,14 @@ class SQLAlchemyCloudRepository:
host_id=host_id, host_id=host_id,
address=address, address=address,
last_seen_at=_iso(last_seen_at), last_seen_at=_iso(last_seen_at),
planner_transport=planner_transport,
) )
) )
return return
if address is not None: if address is not None:
row.address = address row.address = address
row.last_seen_at = _iso(last_seen_at) row.last_seen_at = _iso(last_seen_at)
row.planner_transport = planner_transport
def replace_host_devices( def replace_host_devices(
self, self,
@@ -1419,6 +1422,9 @@ def _host_from_row(row: HostRow) -> Any:
host_id=row.host_id, host_id=row.host_id,
address=row.address, address=row.address,
last_seen_at=_parse_dt(row.last_seen_at) or utc_now(), 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"
),
) )
+13
View File
@@ -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: def test_empty_heartbeat_removes_only_reporting_hosts_devices(tmp_path) -> None:
client, pool = _build_client(tmp_path) client, pool = _build_client(tmp_path)
client.put( client.put(