feat(cloud): report planner transport status
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user