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,
"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())
+1
View File
@@ -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";
+7 -1
View File
@@ -61,6 +61,9 @@ const hostUsageEvents = ref<TokenUsageEvent[]>([]);
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<DeviceRecord, "host_id" | "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>
</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 }},
remaining {{ hostUsage.remaining_tokens ?? "unmetered" }} tokens.
</p>
@@ -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,
@@ -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):
@@ -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
@@ -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):
@@ -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
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(
@@ -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(
+1 -1
View File
@@ -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):
+1
View File
@@ -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()
]
@@ -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):
@@ -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"
),
)
+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:
client, pool = _build_client(tmp_path)
client.put(