feat(cloud): add targeted task governance foundation
Tests / Test passed: 659

This commit is contained in:
2026-07-13 22:21:12 +08:00
parent a3ba94be04
commit 2cd314b183
41 changed files with 2099 additions and 15 deletions
+2
View File
@@ -16,6 +16,8 @@ POOL_READ_SCOPE = "pool:read"
PLUGINS_READ_SCOPE = "plugins:read"
PLUGINS_ADMIN_SCOPE = "plugins:admin"
USERS_ADMIN_SCOPE = "users:admin"
GOVERNANCE_READ_SCOPE = "governance:read"
GOVERNANCE_ADMIN_SCOPE = "governance:admin"
@dataclass(frozen=True)
@@ -215,3 +215,29 @@ class AuthAuditRow(Base):
outcome: Mapped[str] = mapped_column(String, nullable=False)
correlation_id: Mapped[str | None] = mapped_column(String, nullable=True)
metadata_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
class UserSubmissionPolicyRow(Base):
__tablename__ = "cloud_user_submission_policies"
user_id: Mapped[str] = mapped_column(
ForeignKey("cloud_users.id", ondelete="CASCADE"), primary_key=True
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
submission_enabled: Mapped[int] = mapped_column(Integer, nullable=False)
allowed_host_ids_json: Mapped[str | None] = mapped_column(Text, nullable=True)
allowed_device_targets_json: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_at: Mapped[str] = mapped_column(String, nullable=False)
class HostGovernancePolicyRow(Base):
__tablename__ = "cloud_host_governance_policies"
host_id: Mapped[str] = mapped_column(
ForeignKey("host_registrations.host_id", ondelete="CASCADE"), primary_key=True
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
self_submission_enabled: Mapped[int] = mapped_column(Integer, nullable=False)
max_active_tasks: Mapped[int | None] = mapped_column(Integer, nullable=True)
daily_token_budget: Mapped[int | None] = mapped_column(Integer, nullable=True)
updated_at: Mapped[str] = mapped_column(String, nullable=False)
@@ -0,0 +1,61 @@
"""Durable Cloud-side submission and Host governance policies."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
class TaskSubmissionPolicyError(PermissionError):
"""Raised when a human user's policy disallows a task submission."""
@dataclass(frozen=True)
class UserSubmissionPolicy:
user_id: str
revision: int
submission_enabled: bool
allowed_host_ids: tuple[str, ...] | None
allowed_device_targets: tuple[tuple[str, str], ...] | None
updated_at: datetime
@dataclass(frozen=True)
class HostGovernancePolicy:
host_id: str
revision: int
self_submission_enabled: bool
max_active_tasks: int | None
daily_token_budget: int | None
updated_at: datetime
def enforce_user_submission_policy(
policy: UserSubmissionPolicy | None,
*,
target_host_id: str | None,
target_device_id: str | None,
) -> None:
if policy is None:
return
if not policy.submission_enabled:
raise TaskSubmissionPolicyError("task submission is disabled for this user")
restricted = (
policy.allowed_host_ids is not None
or policy.allowed_device_targets is not None
)
if not restricted:
return
if target_host_id is None:
raise TaskSubmissionPolicyError("an explicit permitted target is required")
if (
policy.allowed_host_ids is not None
and target_host_id not in policy.allowed_host_ids
):
raise TaskSubmissionPolicyError("target host is not permitted")
if policy.allowed_device_targets is not None:
if target_device_id is None or (
target_host_id,
target_device_id,
) not in policy.allowed_device_targets:
raise TaskSubmissionPolicyError("target device is not permitted")
@@ -25,8 +25,11 @@ from cloud.internal_api.models import (
DeviceEnrollmentResponse,
HeartbeatRequest,
HeartbeatResponse,
HostGovernancePolicyModel,
HostEnrollmentRequest,
HostEnrollmentResponse,
HostTaskSubmissionRequest,
HostTaskSubmissionResponse,
LeaseRenewalRequest,
LeaseRenewalResponse,
PlannerDecisionError,
@@ -47,6 +50,7 @@ from runtime.tool_specs import ToolSpec
if TYPE_CHECKING:
from cloud.pool import DevicePool
from cloud.scheduler import TaskScheduler
logger = logging.getLogger(__name__)
@@ -60,6 +64,7 @@ def create_internal_router(
lease_duration_seconds: float = 60.0,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
planner_client_factory: Callable[[], ToolCallingClient] | None = None,
scheduler: TaskScheduler | None = None,
) -> APIRouter:
if claim_poll_interval_seconds <= 0:
raise ValueError("claim_poll_interval_seconds must be greater than zero")
@@ -166,12 +171,71 @@ def create_internal_router(
address=payload.address,
allow_device_takeover=allow_device_takeover,
)
policy = pool.store.get_host_governance_policy(host_id)
policy_revision = policy.revision if policy is not None else 0
return HeartbeatResponse(
host_id=host_id,
accepted_devices=len(devices),
received_at=utc_now(),
policy_revision=policy_revision,
policy=(
HostGovernancePolicyModel(
revision=policy.revision,
self_submission_enabled=policy.self_submission_enabled,
max_active_tasks=policy.max_active_tasks,
daily_token_budget=policy.daily_token_budget,
)
if policy is not None and payload.policy_revision != policy.revision
else None
),
)
@router.post(
"/hosts/{host_id}/tasks",
response_model=HostTaskSubmissionResponse,
status_code=status.HTTP_201_CREATED,
)
def submit_host_task(
host_id: str,
payload: HostTaskSubmissionRequest,
request: Request,
) -> HostTaskSubmissionResponse:
authorize_host(request, host_id)
if payload.host_id != host_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="task host_id must match the request path",
)
if scheduler is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="task submission is unavailable",
)
policy = pool.store.get_host_governance_policy(host_id)
if policy is not None and not policy.self_submission_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Host self-submission is disabled",
)
if payload.device_id is not None and not any(
device.host_id == host_id and device.device_id == payload.device_id
for device in pool.list_devices()
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="target device is not owned by this Host",
)
from cloud.scheduler import TaskConstraints
task_id = scheduler.submit(
goal=payload.goal,
constraints=TaskConstraints(
target_host_id=host_id,
target_device_id=payload.device_id,
),
)
return HostTaskSubmissionResponse(task_id=task_id)
@router.post(
"/hosts/{host_id}/assignments/claim",
response_model=ClaimResponse,
@@ -285,6 +349,7 @@ def create_internal_router(
@router.post(
"/hosts/{host_id}/planner/decide",
response_model=PlannerDecisionResponse,
response_model_exclude_none=True,
responses={
status.HTTP_502_BAD_GATEWAY: {"model": PlannerDecisionError},
},
@@ -354,6 +419,9 @@ def create_internal_router(
return PlannerDecisionResponse(
tool_name=decision.tool_name,
arguments=dict(decision.arguments),
input_tokens=(decision.usage.input_tokens if decision.usage else None),
output_tokens=(decision.usage.output_tokens if decision.usage else None),
total_tokens=(decision.usage.total_tokens if decision.usage else None),
)
return router
@@ -38,12 +38,22 @@ class HeartbeatRequest(BaseModel):
host_id: str = Field(min_length=1)
address: str | None = None
devices: list[DeviceSnapshotModel] = Field(default_factory=list)
policy_revision: int = Field(default=0, ge=0)
class HostGovernancePolicyModel(BaseModel):
revision: int = Field(ge=1)
self_submission_enabled: bool
max_active_tasks: int | None = None
daily_token_budget: int | None = None
class HeartbeatResponse(BaseModel):
host_id: str
accepted_devices: int
received_at: datetime
policy_revision: int = Field(default=0, ge=0)
policy: HostGovernancePolicyModel | None = None
class ClaimRequest(BaseModel):
@@ -93,6 +103,16 @@ class TerminalResultResponse(BaseModel):
status: Literal["recorded", "already_recorded"]
class HostTaskSubmissionRequest(BaseModel):
host_id: str = Field(min_length=1)
goal: str = Field(min_length=1)
device_id: str | None = Field(default=None, min_length=1)
class HostTaskSubmissionResponse(BaseModel):
task_id: str
class StaleLeaseConflict(BaseModel):
code: Literal["stale_lease"] = "stale_lease"
detail: str
@@ -116,6 +136,9 @@ class PlannerDecisionRequest(BaseModel):
class PlannerDecisionResponse(BaseModel):
tool_name: str
arguments: dict[str, Any] = Field(default_factory=dict)
input_tokens: int | None = Field(default=None, ge=0)
output_tokens: int | None = Field(default=None, ge=0)
total_tokens: int | None = Field(default=None, ge=0)
class PlannerDecisionError(BaseModel):
@@ -0,0 +1,56 @@
"""Add durable Cloud governance policies."""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0004_cloud_governance"
down_revision = "0003_cloud_user_authentication"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "cloud_user_submission_policies" not in tables:
op.create_table(
"cloud_user_submission_policies",
sa.Column(
"user_id",
sa.String(),
sa.ForeignKey("cloud_users.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("submission_enabled", sa.Integer(), nullable=False),
sa.Column("allowed_host_ids_json", sa.Text(), nullable=True),
sa.Column("allowed_device_targets_json", sa.Text(), nullable=True),
sa.Column("updated_at", sa.String(), nullable=False),
)
if "cloud_host_governance_policies" not in tables:
op.create_table(
"cloud_host_governance_policies",
sa.Column(
"host_id",
sa.String(),
sa.ForeignKey("host_registrations.host_id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("self_submission_enabled", sa.Integer(), nullable=False),
sa.Column("max_active_tasks", sa.Integer(), nullable=True),
sa.Column("daily_token_budget", sa.Integer(), nullable=True),
sa.Column("updated_at", sa.String(), nullable=False),
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "cloud_host_governance_policies" in tables:
op.drop_table("cloud_host_governance_policies")
if "cloud_user_submission_policies" in tables:
op.drop_table("cloud_user_submission_policies")
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
UserAccount,
UserSession,
)
from cloud.governance import HostGovernancePolicy, UserSubmissionPolicy
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
@@ -283,6 +284,30 @@ class CloudRepository(Protocol):
def cleanup_auth_state(self, *, now: datetime, limit: int) -> int: ...
def get_user_submission_policy(self, user_id: str) -> UserSubmissionPolicy | None: ...
def upsert_user_submission_policy(
self,
*,
user_id: str,
submission_enabled: bool,
allowed_host_ids: tuple[str, ...] | None,
allowed_device_targets: tuple[tuple[str, str], ...] | None,
updated_at: datetime,
) -> UserSubmissionPolicy: ...
def get_host_governance_policy(self, host_id: str) -> HostGovernancePolicy | None: ...
def upsert_host_governance_policy(
self,
*,
host_id: str,
self_submission_enabled: bool,
max_active_tasks: int | None,
daily_token_budget: int | None,
updated_at: datetime,
) -> HostGovernancePolicy: ...
def list_reserved_device_ids(self, *, now: datetime) -> set[str]: ...
def assign_task(
@@ -31,6 +31,8 @@ class TaskConstraints:
driver_type: str | None = None
capability_tags: list[str] = field(default_factory=list)
target_host_id: str | None = None
target_device_id: str | None = None
@dataclass
@@ -195,6 +197,10 @@ class TaskScheduler:
def _matches(device: "PooledDevice", constraints: TaskConstraints) -> bool:
if constraints.target_host_id and device.host_id != constraints.target_host_id:
return False
if constraints.target_device_id and device.device_id != constraints.target_device_id:
return False
if constraints.driver_type and device.driver_type != constraints.driver_type:
return False
if constraints.capability_tags:
+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 = "0003_cloud_user_authentication"
HEAD_REVISION = "0004_cloud_governance"
class SchemaVersionError(RuntimeError):
+51 -2
View File
@@ -22,6 +22,7 @@ from cloud.auth import (
NullAuthProvider,
Principal,
)
from cloud.governance import TaskSubmissionPolicyError, enforce_user_submission_policy
from cloud.sdk.models import (
DeviceResponse,
ErrorResponse,
@@ -94,8 +95,21 @@ def create_cloud_router(
payload: TaskSubmissionRequest,
request: Request,
) -> TaskSubmissionResponse:
_authorize(request, TASKS_SUBMIT_SCOPE)
task_constraints = _build_constraints(payload.constraints)
principal = _authorize(request, TASKS_SUBMIT_SCOPE)
try:
task_constraints = _build_constraints(payload.constraints)
_validate_task_target(pool, task_constraints)
_enforce_user_policy(principal, scheduler.store, task_constraints)
except TaskSubmissionPolicyError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
) from exc
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
try:
task_id = scheduler.submit(
goal=payload.goal,
@@ -128,6 +142,8 @@ def create_cloud_router(
attempt_count=task.attempt_count,
lease_expires_at=task.lease_expires_at,
failure_reason=task.failure_reason,
target_host_id=task.constraints.target_host_id,
target_device_id=task.constraints.target_device_id,
)
@router.get("/tasks", response_model=TaskListResponse)
@@ -158,6 +174,8 @@ def create_cloud_router(
assigned_host_id=task.assigned_host_id,
attempt_count=task.attempt_count,
failure_reason=task.failure_reason,
target_host_id=task.constraints.target_host_id,
target_device_id=task.constraints.target_device_id,
created_at=task.created_at,
)
for task in tasks
@@ -303,4 +321,35 @@ def _build_constraints(model):
return TaskConstraints(
driver_type=model.driver_type,
capability_tags=list(model.capability_tags),
target_host_id=model.target_host_id,
target_device_id=model.target_device_id,
)
def _validate_task_target(pool, constraints) -> None:
if constraints.target_device_id and not constraints.target_host_id:
raise ValueError("target_device_id requires target_host_id")
if constraints.target_host_id is None:
return
if not any(host.host_id == constraints.target_host_id for host in pool.list_hosts()):
raise ValueError(f"target host {constraints.target_host_id!r} is not known")
if constraints.target_device_id is not None and not any(
device.host_id == constraints.target_host_id
and device.device_id == constraints.target_device_id
for device in pool.list_devices()
):
raise ValueError(
f"target device {constraints.target_device_id!r} is not owned by "
f"host {constraints.target_host_id!r}"
)
def _enforce_user_policy(principal, store, constraints) -> None:
if not principal.id.startswith("user:"):
return
policy = store.get_user_submission_policy(principal.id.removeprefix("user:"))
enforce_user_submission_policy(
policy,
target_host_id=constraints.target_host_id,
target_device_id=constraints.target_device_id,
)
+32 -1
View File
@@ -72,15 +72,24 @@ class CloudClient:
workflow_definition_id: str | None = None,
driver_type: str | None = None,
capability_tags: list[str] | None = None,
target_host_id: str | None = None,
target_device_id: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"goal": goal,
"workflow_definition_id": workflow_definition_id,
}
if driver_type is not None or capability_tags is not None:
if (
driver_type is not None
or capability_tags is not None
or target_host_id is not None
or target_device_id is not None
):
payload["constraints"] = {
"driver_type": driver_type,
"capability_tags": list(capability_tags or []),
"target_host_id": target_host_id,
"target_device_id": target_device_id,
}
resp = self._request("POST", "/tasks", json=payload)
return resp.json()
@@ -209,6 +218,28 @@ class CloudClient:
def revoke_user_sessions(self, user_id: str) -> None:
self._request("DELETE", f"/users/{user_id}/sessions")
# ------------------------------------------------------------- governance
def get_user_submission_policy(self, user_id: str) -> dict[str, Any]:
return self._request("GET", f"/users/{user_id}/submission-policy").json()
def update_user_submission_policy(
self, user_id: str, **policy: Any
) -> dict[str, Any]:
return self._request(
"PUT", f"/users/{user_id}/submission-policy", json=policy
).json()
def get_host_governance_policy(self, host_id: str) -> dict[str, Any]:
return self._request("GET", f"/hosts/{host_id}/governance-policy").json()
def update_host_governance_policy(
self, host_id: str, **policy: Any
) -> dict[str, Any]:
return self._request(
"PUT", f"/hosts/{host_id}/governance-policy", json=policy
).json()
# ------------------------------------------------------------------ helpers
def _url(self, path: str) -> str:
@@ -0,0 +1,169 @@
from __future__ import annotations
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, status
from cloud.auth import AuthProvider, GOVERNANCE_ADMIN_SCOPE, GOVERNANCE_READ_SCOPE
from cloud.observability import current_correlation_id
from cloud.sdk.models import (
HostGovernancePolicyRequest,
HostGovernancePolicyResponse,
UserSubmissionPolicyRequest,
UserSubmissionPolicyResponse,
)
from cloud.user_auth import AuthAuditEvent
from core.models import utc_now
def create_governance_router(*, repository, auth_provider: AuthProvider) -> APIRouter:
router = APIRouter(prefix="/v1", tags=["cloud-governance"])
def authorize(request: Request, required_scope: str):
principal = auth_provider.authenticate(request)
if principal is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
headers={"WWW-Authenticate": "Bearer"},
)
if principal.must_change_password or not principal.has_scope(required_scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"missing required scope: {required_scope}",
)
return principal
@router.get(
"/users/{user_id}/submission-policy",
response_model=UserSubmissionPolicyResponse,
)
def get_user_policy(user_id: str, request: Request) -> UserSubmissionPolicyResponse:
authorize(request, GOVERNANCE_READ_SCOPE)
policy = repository.get_user_submission_policy(user_id)
if policy is None:
raise HTTPException(status_code=404, detail="submission policy not found")
return UserSubmissionPolicyResponse(
user_id=policy.user_id,
revision=policy.revision,
submission_enabled=policy.submission_enabled,
allowed_host_ids=list(policy.allowed_host_ids)
if policy.allowed_host_ids is not None
else None,
allowed_device_targets=[
{"host_id": host_id, "device_id": device_id}
for host_id, device_id in policy.allowed_device_targets or ()
]
if policy.allowed_device_targets is not None
else None,
updated_at=policy.updated_at,
)
@router.put(
"/users/{user_id}/submission-policy",
response_model=UserSubmissionPolicyResponse,
)
def put_user_policy(
user_id: str,
payload: UserSubmissionPolicyRequest,
request: Request,
) -> UserSubmissionPolicyResponse:
principal = authorize(request, GOVERNANCE_ADMIN_SCOPE)
try:
policy = repository.upsert_user_submission_policy(
user_id=user_id,
submission_enabled=payload.submission_enabled,
allowed_host_ids=_unique_strings(payload.allowed_host_ids),
allowed_device_targets=(
tuple((item.host_id, item.device_id) for item in payload.allowed_device_targets)
if payload.allowed_device_targets is not None
else None
),
updated_at=utc_now(),
)
except KeyError as exc:
raise HTTPException(status_code=404, detail="user not found") from exc
_audit(repository, principal.id, user_id, "user_submission_policy_update")
return UserSubmissionPolicyResponse(
user_id=policy.user_id,
revision=policy.revision,
submission_enabled=policy.submission_enabled,
allowed_host_ids=list(policy.allowed_host_ids)
if policy.allowed_host_ids is not None
else None,
allowed_device_targets=[
{"host_id": host_id, "device_id": device_id}
for host_id, device_id in policy.allowed_device_targets or ()
]
if policy.allowed_device_targets is not None
else None,
updated_at=policy.updated_at,
)
@router.get(
"/hosts/{host_id}/governance-policy",
response_model=HostGovernancePolicyResponse,
)
def get_host_policy(host_id: str, request: Request) -> HostGovernancePolicyResponse:
authorize(request, GOVERNANCE_READ_SCOPE)
policy = repository.get_host_governance_policy(host_id)
if policy is None:
raise HTTPException(status_code=404, detail="Host policy not found")
return _host_response(policy)
@router.put(
"/hosts/{host_id}/governance-policy",
response_model=HostGovernancePolicyResponse,
)
def put_host_policy(
host_id: str,
payload: HostGovernancePolicyRequest,
request: Request,
) -> HostGovernancePolicyResponse:
principal = authorize(request, GOVERNANCE_ADMIN_SCOPE)
try:
policy = repository.upsert_host_governance_policy(
host_id=host_id,
self_submission_enabled=payload.self_submission_enabled,
max_active_tasks=payload.max_active_tasks,
daily_token_budget=payload.daily_token_budget,
updated_at=utc_now(),
)
except KeyError as exc:
raise HTTPException(status_code=404, detail="Host not found") from exc
_audit(repository, principal.id, host_id, "host_governance_policy_update")
return _host_response(policy)
return router
def _unique_strings(values: list[str] | None) -> tuple[str, ...] | None:
if values is None:
return None
return tuple(dict.fromkeys(value for value in values if value))
def _host_response(policy) -> HostGovernancePolicyResponse:
return HostGovernancePolicyResponse(
host_id=policy.host_id,
revision=policy.revision,
self_submission_enabled=policy.self_submission_enabled,
max_active_tasks=policy.max_active_tasks,
daily_token_budget=policy.daily_token_budget,
updated_at=policy.updated_at,
)
def _audit(repository, actor_id: str, target: str, action: str) -> None:
repository.record_auth_audit(
AuthAuditEvent(
id=uuid4().hex,
occurred_at=utc_now(),
actor_principal_id=actor_id,
target_user_id=target if action.startswith("user_") else None,
action=action,
outcome="success",
correlation_id=current_correlation_id(),
metadata={"target": target},
)
)
@@ -11,6 +11,8 @@ from pydantic import BaseModel, Field
class TaskConstraintsModel(BaseModel):
driver_type: str | None = None
capability_tags: list[str] = Field(default_factory=list)
target_host_id: str | None = Field(default=None, min_length=1)
target_device_id: str | None = Field(default=None, min_length=1)
class TaskSubmissionRequest(BaseModel):
@@ -33,6 +35,8 @@ class TaskStatusResponse(BaseModel):
attempt_count: int = 0
lease_expires_at: datetime | None = None
failure_reason: str | None = None
target_host_id: str | None = None
target_device_id: str | None = None
class TaskListItem(BaseModel):
@@ -44,6 +48,8 @@ class TaskListItem(BaseModel):
assigned_host_id: str | None = None
attempt_count: int = 0
failure_reason: str | None = None
target_host_id: str | None = None
target_device_id: str | None = None
created_at: datetime
@@ -145,3 +151,32 @@ class PasswordResetRequest(BaseModel):
class ErrorResponse(BaseModel):
detail: str
class DeviceTargetModel(BaseModel):
host_id: str = Field(min_length=1)
device_id: str = Field(min_length=1)
class UserSubmissionPolicyRequest(BaseModel):
submission_enabled: bool = True
allowed_host_ids: list[str] | None = None
allowed_device_targets: list[DeviceTargetModel] | None = None
class UserSubmissionPolicyResponse(UserSubmissionPolicyRequest):
user_id: str
revision: int
updated_at: datetime
class HostGovernancePolicyRequest(BaseModel):
self_submission_enabled: bool = True
max_active_tasks: int | None = Field(default=None, ge=1)
daily_token_budget: int | None = Field(default=None, ge=1)
class HostGovernancePolicyResponse(HostGovernancePolicyRequest):
host_id: str
revision: int
updated_at: datetime
@@ -19,9 +19,11 @@ from cloud.db_models import (
ScheduledTaskRow,
TaskAttemptRow,
AuthAuditRow,
HostGovernancePolicyRow,
LoginThrottleRow,
UserRow,
UserSessionRow,
UserSubmissionPolicyRow,
)
from cloud.observability import current_correlation_id
from core.models import utc_now
@@ -807,6 +809,86 @@ class SQLAlchemyCloudRepository:
removed += len(stale_throttles)
return removed
# -------------------------------------------------------------- governance
def get_user_submission_policy(self, user_id: str) -> Any | None:
with self._sessions() as session:
row = session.get(UserSubmissionPolicyRow, user_id)
return _user_submission_policy_from_row(row) if row is not None else None
def upsert_user_submission_policy(
self,
*,
user_id: str,
submission_enabled: bool,
allowed_host_ids: tuple[str, ...] | None,
allowed_device_targets: tuple[tuple[str, str], ...] | None,
updated_at: datetime,
) -> Any:
with self._sessions.begin() as session:
if session.get(UserRow, user_id) is None:
raise KeyError(f"unknown user {user_id!r}")
row = session.get(UserSubmissionPolicyRow, user_id)
if row is None:
row = UserSubmissionPolicyRow(
user_id=user_id,
revision=1,
submission_enabled=1 if submission_enabled else 0,
allowed_host_ids_json=_dump_optional_list(allowed_host_ids),
allowed_device_targets_json=_dump_optional_list(
allowed_device_targets
),
updated_at=_iso(updated_at),
)
session.add(row)
else:
row.revision += 1
row.submission_enabled = 1 if submission_enabled else 0
row.allowed_host_ids_json = _dump_optional_list(allowed_host_ids)
row.allowed_device_targets_json = _dump_optional_list(
allowed_device_targets
)
row.updated_at = _iso(updated_at)
session.flush()
return _user_submission_policy_from_row(row)
def get_host_governance_policy(self, host_id: str) -> Any | None:
with self._sessions() as session:
row = session.get(HostGovernancePolicyRow, host_id)
return _host_governance_policy_from_row(row) if row is not None else None
def upsert_host_governance_policy(
self,
*,
host_id: str,
self_submission_enabled: bool,
max_active_tasks: int | None,
daily_token_budget: int | None,
updated_at: datetime,
) -> Any:
with self._sessions.begin() as session:
if session.get(HostRow, host_id) is None:
raise KeyError(f"unknown host {host_id!r}")
row = session.get(HostGovernancePolicyRow, host_id)
if row is None:
row = HostGovernancePolicyRow(
host_id=host_id,
revision=1,
self_submission_enabled=1 if self_submission_enabled else 0,
max_active_tasks=max_active_tasks,
daily_token_budget=daily_token_budget,
updated_at=_iso(updated_at),
)
session.add(row)
else:
row.revision += 1
row.self_submission_enabled = 1 if self_submission_enabled else 0
row.max_active_tasks = max_active_tasks
row.daily_token_budget = daily_token_budget
row.updated_at = _iso(updated_at)
session.flush()
return _host_governance_policy_from_row(row)
def list_reserved_device_ids(self, *, now: datetime) -> set[str]:
with self._sessions() as session:
device_ids = session.scalars(
@@ -1218,6 +1300,8 @@ def _task_from_row(row: ScheduledTaskRow) -> Any:
constraints=TaskConstraints(
driver_type=constraints_data.get("driver_type"),
capability_tags=list(constraints_data.get("capability_tags") or []),
target_host_id=constraints_data.get("target_host_id"),
target_device_id=constraints_data.get("target_device_id"),
),
status=row.status,
assigned_device_id=row.assigned_device_id,
@@ -1232,6 +1316,66 @@ def _task_from_row(row: ScheduledTaskRow) -> Any:
)
def _dump_optional_list(value: tuple[Any, ...] | None) -> str | None:
return json.dumps(value, ensure_ascii=False) if value is not None else None
def _load_optional_string_tuple(value: str | None) -> tuple[str, ...] | None:
if value is None:
return None
try:
parsed = json.loads(value)
except (TypeError, ValueError):
parsed = []
return tuple(item for item in parsed if isinstance(item, str))
def _load_optional_device_targets(
value: str | None,
) -> tuple[tuple[str, str], ...] | None:
if value is None:
return None
try:
parsed = json.loads(value)
except (TypeError, ValueError):
parsed = []
return tuple(
(item[0], item[1])
for item in parsed
if isinstance(item, list | tuple)
and len(item) == 2
and all(isinstance(part, str) for part in item)
)
def _user_submission_policy_from_row(row: UserSubmissionPolicyRow) -> Any:
from cloud.governance import UserSubmissionPolicy
return UserSubmissionPolicy(
user_id=row.user_id,
revision=row.revision,
submission_enabled=bool(row.submission_enabled),
allowed_host_ids=_load_optional_string_tuple(row.allowed_host_ids_json),
allowed_device_targets=_load_optional_device_targets(
row.allowed_device_targets_json
),
updated_at=_parse_dt(row.updated_at) or utc_now(),
)
def _host_governance_policy_from_row(row: HostGovernancePolicyRow) -> Any:
from cloud.governance import HostGovernancePolicy
return HostGovernancePolicy(
host_id=row.host_id,
revision=row.revision,
self_submission_enabled=bool(row.self_submission_enabled),
max_active_tasks=row.max_active_tasks,
daily_token_budget=row.daily_token_budget,
updated_at=_parse_dt(row.updated_at) or utc_now(),
)
def _task_attempt_from_row(row: TaskAttemptRow) -> Any:
from cloud.repository import TaskAttemptRecord