598cecad5f
Co-authored-by: Cursor <cursoragent@cursor.com>
542 lines
20 KiB
Python
542 lines
20 KiB
Python
"""对外 HTTP 接口。业务规则在 services 中,这里只做参数转发。"""
|
|
|
|
from datetime import date, timedelta
|
|
from decimal import Decimal
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
from fastapi.responses import Response
|
|
from sqlalchemy import case, func, select
|
|
from sqlalchemy.orm import Session, selectinload
|
|
|
|
from app.clock import utcnow
|
|
from app.config import settings
|
|
from app.database import get_db
|
|
from app.deps import AdminUser, CurrentUser
|
|
from app.errors import AppError
|
|
from app.models import (
|
|
ComputeNode,
|
|
ComputeTask,
|
|
DatacenterApplication,
|
|
IPWhitelist,
|
|
LicenseOrder,
|
|
User,
|
|
UserRole,
|
|
WithdrawalLog,
|
|
WithdrawalStatus,
|
|
)
|
|
from app.money import money
|
|
from app.schemas import (
|
|
AdminUserOut,
|
|
BalanceOut,
|
|
ContributionOut,
|
|
DashboardOut,
|
|
HeartbeatIn,
|
|
HeartbeatOut,
|
|
LoginIn,
|
|
MeNodeCreate,
|
|
OrderCreate,
|
|
OrderOut,
|
|
OrderView,
|
|
MeWithdrawalCreate,
|
|
NodeCreate,
|
|
NodeOut,
|
|
NodeView,
|
|
IPWhitelistCreate,
|
|
IPWhitelistOut,
|
|
BanToggle,
|
|
WhitelistToggle,
|
|
RegisterIn,
|
|
SettleOut,
|
|
SummaryOut,
|
|
AdminTaskOut,
|
|
TaskCreate,
|
|
TaskOut,
|
|
TaskReportIn,
|
|
TokenOut,
|
|
UserCreate,
|
|
UserOut,
|
|
WithdrawalConfirm,
|
|
WithdrawalCreate,
|
|
WithdrawalOut,
|
|
DatacenterApply,
|
|
DatacenterOut,
|
|
DatacenterView,
|
|
WithdrawalReject,
|
|
WithdrawalView,
|
|
YieldOut,
|
|
)
|
|
from app.security import create_access_token
|
|
from app.services.auth import authenticate, register_account
|
|
from app.services.common import get_node_by_sn, get_user_by_wallet
|
|
from app.services.datacenter import approve_application, list_applications, reject_application, submit_application
|
|
from app.services.hardware import HardwareReport
|
|
from app.services.hardware import display_node_id, displayed_population
|
|
from app.services.alpha_queue import assign_queued_alpha_task, maybe_generate_alpha_batch
|
|
from app.services.heartbeat import assert_node_token, record_heartbeat, set_node_banned
|
|
from app.services.ip_guard import add_whitelist_ip, client_ip, list_whitelist, remove_whitelist_ip
|
|
from app.services.orders import approve_order, list_orders, place_order, reject_order, render_client
|
|
from app.services.portal import (
|
|
authorized_amount,
|
|
live_nodes,
|
|
node_counts,
|
|
pending_withdrawal_amount,
|
|
settled_points,
|
|
user_rows,
|
|
)
|
|
from app.services.registry import register_node, register_user
|
|
from app.services.tasks import create_admin_tasks, list_admin_tasks, list_pending_tasks, report_task, result_preview
|
|
from app.services.withdrawals import available_balance, confirm_withdrawal, reject_withdrawal, request_withdrawal
|
|
from app.services.yield_engine import compute_node_yield, list_node_yields, network_yields, settle_yield_date
|
|
|
|
router = APIRouter(prefix="/api/v1")
|
|
Db = Annotated[Session, Depends(get_db)]
|
|
|
|
|
|
@router.post("/auth/register", response_model=TokenOut)
|
|
def register(body: RegisterIn, db: Db) -> TokenOut:
|
|
user = register_account(db, body.wallet_address, body.username, body.password)
|
|
return _token(user)
|
|
|
|
|
|
@router.post("/auth/login", response_model=TokenOut)
|
|
def login(body: LoginIn, db: Db) -> TokenOut:
|
|
user = authenticate(db, body.wallet_address, body.username, body.password)
|
|
return _token(user)
|
|
|
|
|
|
@router.get("/network")
|
|
def network_population(db: Db) -> dict[str, int]:
|
|
nodes, online = node_counts(db)
|
|
return {"nodes": displayed_population(nodes), "online": displayed_population(online)}
|
|
|
|
|
|
@router.get("/me", response_model=UserOut)
|
|
def me(user: CurrentUser) -> User:
|
|
return user
|
|
|
|
|
|
@router.get("/me/dashboard", response_model=DashboardOut)
|
|
def my_dashboard(user: CurrentUser, db: Db) -> DashboardOut:
|
|
nodes = live_nodes(db, user.id)
|
|
withdrawals = db.scalars(
|
|
select(WithdrawalLog).where(WithdrawalLog.user_id == user.id).order_by(WithdrawalLog.id.desc()).limit(20)
|
|
).all()
|
|
today = money(sum((item["today_points"] for item in nodes), Decimal("0")))
|
|
network_total, network_online = node_counts(db)
|
|
return DashboardOut(
|
|
wallet_address=user.wallet_address,
|
|
username=user.username,
|
|
role=user.role,
|
|
available_points=available_balance(db, user.id),
|
|
settled_points=settled_points(db, user.id),
|
|
today_points=today,
|
|
nodes=[NodeView.model_validate(item) for item in nodes],
|
|
orders=[_order_out(item) for item in list_orders(db, user.id)],
|
|
license_price=money(settings.license_price),
|
|
license_asset=settings.license_asset,
|
|
network_nodes=displayed_population(network_total),
|
|
network_online=displayed_population(network_online),
|
|
withdrawals=[WithdrawalOut.model_validate(item) for item in withdrawals],
|
|
datacenter_applications=[_datacenter_out(item) for item in list_applications(db, user.id)],
|
|
)
|
|
|
|
|
|
@router.post("/me/orders", response_model=OrderOut)
|
|
def create_my_order(body: OrderCreate, user: CurrentUser, db: Db) -> OrderOut:
|
|
return _order_out(place_order(db, user.id, body.tx_id))
|
|
|
|
|
|
@router.get("/me/nodes/{sn}/client")
|
|
def download_client(sn: str, request: Request, user: CurrentUser, db: Db) -> Response:
|
|
node = _owned_node(db, user, sn)
|
|
if not node.node_token:
|
|
raise AppError("节点尚未开通,不能下载客户端", 403)
|
|
script = render_client(node.sn, node.node_token, str(request.base_url))
|
|
filename = f"hypernode-{node.sn}.py"
|
|
return Response(
|
|
content=script,
|
|
media_type="text/x-python; charset=utf-8",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@router.post("/me/nodes", response_model=NodeOut)
|
|
def create_my_node(body: MeNodeCreate, request: Request, user: CurrentUser, db: Db) -> NodeOut:
|
|
return register_node(
|
|
db, user.wallet_address, body.sn, body.purchase_amount, body.callback_url, client_ip=client_ip(request)
|
|
)
|
|
|
|
|
|
@router.post("/me/datacenter-applications", response_model=DatacenterOut)
|
|
def create_datacenter_application(body: DatacenterApply, user: CurrentUser, db: Db) -> DatacenterOut:
|
|
return _datacenter_out(
|
|
submit_application(
|
|
db,
|
|
user.id,
|
|
body.contact,
|
|
body.node_count,
|
|
body.subnet_ip,
|
|
body.hardware_proof,
|
|
body.gpu_generation,
|
|
)
|
|
)
|
|
|
|
|
|
@router.post("/me/withdrawals", response_model=WithdrawalOut)
|
|
def create_my_withdrawal(body: MeWithdrawalCreate, user: CurrentUser, db: Db) -> WithdrawalOut:
|
|
row, remaining = request_withdrawal(db, user.wallet_address, body.amount, body.asset_symbol)
|
|
return WithdrawalOut.model_validate(row).model_copy(update={"available_after": remaining})
|
|
|
|
|
|
@router.get("/admin/summary", response_model=SummaryOut)
|
|
def admin_summary(_: AdminUser, db: Db) -> SummaryOut:
|
|
return _summary(db)
|
|
|
|
|
|
@router.get("/admin/users", response_model=list[AdminUserOut])
|
|
def admin_users(_: AdminUser, db: Db) -> list[dict]:
|
|
return user_rows(db)
|
|
|
|
|
|
@router.get("/admin/nodes", response_model=list[NodeView])
|
|
def admin_nodes(_: AdminUser, db: Db) -> list[NodeView]:
|
|
return [NodeView.model_validate(item) for item in live_nodes(db)]
|
|
|
|
|
|
@router.post("/admin/nodes/{sn}/whitelist", response_model=NodeView)
|
|
def admin_toggle_node_whitelist(sn: str, body: WhitelistToggle, _: AdminUser, db: Db) -> NodeView:
|
|
node = get_node_by_sn(db, sn)
|
|
node.is_whitelist = body.is_whitelist
|
|
node.updated_at = utcnow()
|
|
db.flush()
|
|
snapshot = next(item for item in live_nodes(db) if item["sn"] == node.sn)
|
|
return NodeView.model_validate(snapshot)
|
|
|
|
|
|
@router.post("/admin/nodes/{sn}/ban", response_model=NodeView)
|
|
def admin_ban_node(sn: str, body: BanToggle, _: AdminUser, db: Db) -> NodeView:
|
|
node = get_node_by_sn(db, sn)
|
|
set_node_banned(db, node, body.is_banned)
|
|
snapshot = next(item for item in live_nodes(db) if item["sn"] == node.sn)
|
|
return NodeView.model_validate(snapshot)
|
|
|
|
|
|
@router.get("/admin/ip-whitelist", response_model=list[IPWhitelistOut])
|
|
def admin_ip_whitelist(_: AdminUser, db: Db) -> list[IPWhitelist]:
|
|
return list_whitelist(db)
|
|
|
|
|
|
@router.post("/admin/ip-whitelist", response_model=IPWhitelistOut)
|
|
def admin_add_ip_whitelist(body: IPWhitelistCreate, _: AdminUser, db: Db) -> IPWhitelist:
|
|
return add_whitelist_ip(db, body.ip_address, body.note)
|
|
|
|
|
|
@router.delete("/admin/ip-whitelist/{entry_id}", response_model=IPWhitelistOut)
|
|
def admin_remove_ip_whitelist(entry_id: int, _: AdminUser, db: Db) -> IPWhitelist:
|
|
return remove_whitelist_ip(db, entry_id)
|
|
|
|
|
|
@router.get("/admin/datacenter-applications", response_model=list[DatacenterView])
|
|
def admin_datacenter_applications(_: AdminUser, db: Db) -> list[DatacenterView]:
|
|
return [_datacenter_view(row) for row in list_applications(db)]
|
|
|
|
|
|
@router.post("/admin/datacenter-applications/{application_id}/approve", response_model=DatacenterView)
|
|
def admin_approve_datacenter(application_id: int, _: AdminUser, db: Db) -> DatacenterView:
|
|
return _datacenter_view(approve_application(db, application_id))
|
|
|
|
|
|
@router.post("/admin/datacenter-applications/{application_id}/reject", response_model=DatacenterView)
|
|
def admin_reject_datacenter(application_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> DatacenterView:
|
|
return _datacenter_view(reject_application(db, application_id, body.note))
|
|
|
|
|
|
@router.get("/admin/orders", response_model=list[OrderView])
|
|
def admin_orders(_: AdminUser, db: Db) -> list[OrderView]:
|
|
return [_order_view(row) for row in list_orders(db)]
|
|
|
|
|
|
@router.post("/admin/orders/{order_id}/approve", response_model=OrderView)
|
|
def admin_approve_order(order_id: int, _: AdminUser, db: Db) -> OrderView:
|
|
return _order_view(approve_order(db, order_id))
|
|
|
|
|
|
@router.post("/admin/orders/{order_id}/reject", response_model=OrderView)
|
|
def admin_reject_order(order_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> OrderView:
|
|
return _order_view(reject_order(db, order_id, body.note))
|
|
|
|
|
|
@router.get("/admin/withdrawals", response_model=list[WithdrawalView])
|
|
def admin_withdrawals(_: AdminUser, db: Db) -> list[WithdrawalView]:
|
|
return [_withdrawal_view(row) for row in _withdrawal_rows(db)]
|
|
|
|
|
|
@router.post("/admin/withdrawals/{withdrawal_id}/confirm", response_model=WithdrawalOut)
|
|
def admin_confirm(withdrawal_id: int, body: WithdrawalConfirm, _: AdminUser, db: Db) -> WithdrawalOut:
|
|
return confirm_withdrawal(db, withdrawal_id, body.tx_hash)
|
|
|
|
|
|
@router.post("/admin/withdrawals/{withdrawal_id}/reject", response_model=WithdrawalOut)
|
|
def admin_reject(withdrawal_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> WithdrawalOut:
|
|
return reject_withdrawal(db, withdrawal_id, body.note)
|
|
|
|
|
|
@router.get("/summary", response_model=SummaryOut)
|
|
def summary(_: AdminUser, db: Db) -> SummaryOut:
|
|
return _summary(db)
|
|
|
|
|
|
@router.get("/users", response_model=list[UserOut])
|
|
def list_users(_: AdminUser, db: Db) -> list[User]:
|
|
return list(db.scalars(select(User).order_by(User.id.desc())).all())
|
|
|
|
|
|
@router.post("/users", response_model=UserOut)
|
|
def create_user(body: UserCreate, db: Db) -> UserOut:
|
|
return register_user(db, body.wallet_address)
|
|
|
|
|
|
@router.get("/nodes", response_model=list[NodeView])
|
|
def list_nodes(_: AdminUser, db: Db) -> list[NodeView]:
|
|
return [NodeView.model_validate(item) for item in live_nodes(db)]
|
|
|
|
|
|
@router.post("/nodes", response_model=NodeOut)
|
|
def create_node(body: NodeCreate, request: Request, user: CurrentUser, db: Db) -> NodeOut:
|
|
_must_own_wallet(user, body.wallet_address)
|
|
return register_node(
|
|
db, body.wallet_address, body.sn, body.purchase_amount, body.callback_url, client_ip=client_ip(request)
|
|
)
|
|
|
|
|
|
@router.get("/nodes/{sn}", response_model=NodeOut)
|
|
def get_node(sn: str, user: CurrentUser, db: Db) -> NodeOut:
|
|
return _owned_node(db, user, sn)
|
|
|
|
|
|
@router.post("/node/heartbeat", response_model=HeartbeatOut)
|
|
def heartbeat(body: HeartbeatIn, request: Request, db: Db) -> HeartbeatOut:
|
|
hardware = None
|
|
if {"gpu_model", "vram_gb", "cpu_count", "memory_gb"} & body.model_fields_set:
|
|
hardware = HardwareReport(body.gpu_model, body.vram_gb, body.cpu_count, body.memory_gb)
|
|
node = record_heartbeat(
|
|
db,
|
|
body.sn,
|
|
body.token,
|
|
hardware=hardware,
|
|
client_ip=client_ip(request),
|
|
hardware_fingerprint=body.hardware_fingerprint,
|
|
)
|
|
maybe_generate_alpha_batch(db)
|
|
assigned = assign_queued_alpha_task(db, node)
|
|
days = node.recovery_days or 540
|
|
return HeartbeatOut(
|
|
sn=node.sn,
|
|
display_node_id=node.display_node_id,
|
|
status=node.status,
|
|
last_heartbeat_at=node.last_heartbeat_at,
|
|
server_time=node.last_heartbeat_at,
|
|
hardware_weight=node.hardware_weight,
|
|
gpu_generation=node.gpu_generation,
|
|
recovery_days=node.recovery_days,
|
|
recoverable_stake_amount=node.recoverable_stake_amount,
|
|
purchase_amount=node.purchase_amount,
|
|
estimated_daily_points=money(node.purchase_amount / Decimal(days)),
|
|
assigned_task=_task_out(assigned) if assigned is not None else None,
|
|
)
|
|
|
|
|
|
@router.get("/node/{sn}/tasks", response_model=list[TaskOut])
|
|
def pending_tasks(sn: str, db: Db, token: str | None = None) -> list[TaskOut]:
|
|
assert_node_token(get_node_by_sn(db, sn), token)
|
|
return [_task_out(task) for task in list_pending_tasks(db, sn)]
|
|
|
|
|
|
@router.post("/node/task/report", response_model=TaskOut)
|
|
def report(body: TaskReportIn, db: Db) -> TaskOut:
|
|
assert_node_token(get_node_by_sn(db, body.sn), body.token)
|
|
return _task_out(report_task(db, body.sn, body.task_id, body.success, body.nonce, result=body.result))
|
|
|
|
|
|
@router.post("/admin/tasks/create", response_model=list[AdminTaskOut])
|
|
def admin_create_tasks(body: TaskCreate, _: AdminUser, db: Db) -> list[AdminTaskOut]:
|
|
tasks = create_admin_tasks(db, body.task_type, body.payload, body.target_node_sn)
|
|
return [_admin_task_out(task) for task in tasks]
|
|
|
|
|
|
@router.get("/admin/tasks/list", response_model=list[AdminTaskOut])
|
|
def admin_list_tasks(_: AdminUser, db: Db) -> list[AdminTaskOut]:
|
|
return [_admin_task_out(task) for task in list_admin_tasks(db)]
|
|
|
|
|
|
@router.get("/nodes/{sn}/contribution", response_model=ContributionOut)
|
|
def contribution(sn: str, yield_date: date, user: CurrentUser, db: Db) -> ContributionOut:
|
|
node = _owned_node(db, user, sn)
|
|
computed = next(
|
|
(item for current, item in network_yields(db, yield_date) if current.id == node.id),
|
|
None,
|
|
)
|
|
if computed is None:
|
|
computed = compute_node_yield(db, node, yield_date)
|
|
return ContributionOut(sn=node.sn, **computed.__dict__)
|
|
|
|
|
|
@router.post("/yield/settle", response_model=SettleOut)
|
|
def settle(_: AdminUser, db: Db, yield_date: date | None = None) -> SettleOut:
|
|
target = yield_date or (utcnow().date() - timedelta(days=1))
|
|
summary = settle_yield_date(db, target)
|
|
return SettleOut(
|
|
yield_date=summary.yield_date,
|
|
created=summary.created,
|
|
skipped=summary.skipped,
|
|
total_points=summary.total_points,
|
|
is_partial=summary.is_partial,
|
|
)
|
|
|
|
|
|
@router.get("/nodes/{sn}/yields", response_model=list[YieldOut])
|
|
def yields(sn: str, user: CurrentUser, db: Db) -> list[YieldOut]:
|
|
_owned_node(db, user, sn)
|
|
return list_node_yields(db, sn)
|
|
|
|
|
|
@router.get("/withdrawals", response_model=list[WithdrawalView])
|
|
def list_withdrawals(_: AdminUser, db: Db) -> list[WithdrawalView]:
|
|
return [_withdrawal_view(row) for row in _withdrawal_rows(db)]
|
|
|
|
|
|
@router.get("/users/{wallet_address}/balance", response_model=BalanceOut)
|
|
def balance(wallet_address: str, user: CurrentUser, db: Db) -> BalanceOut:
|
|
_must_own_wallet(user, wallet_address)
|
|
owner = get_user_by_wallet(db, wallet_address)
|
|
return BalanceOut(wallet_address=owner.wallet_address, available_points=available_balance(db, owner.id))
|
|
|
|
|
|
@router.post("/withdrawals", response_model=WithdrawalOut)
|
|
def create_withdrawal(body: WithdrawalCreate, user: CurrentUser, db: Db) -> WithdrawalOut:
|
|
_must_own_wallet(user, body.wallet_address)
|
|
row, remaining = request_withdrawal(db, body.wallet_address, body.amount, body.asset_symbol)
|
|
return WithdrawalOut.model_validate(row).model_copy(update={"available_after": remaining})
|
|
|
|
|
|
@router.post("/withdrawals/{withdrawal_id}/confirm", response_model=WithdrawalOut)
|
|
def confirm(withdrawal_id: int, body: WithdrawalConfirm, _: AdminUser, db: Db) -> WithdrawalOut:
|
|
return confirm_withdrawal(db, withdrawal_id, body.tx_hash)
|
|
|
|
|
|
@router.post("/withdrawals/{withdrawal_id}/reject", response_model=WithdrawalOut)
|
|
def reject(withdrawal_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> WithdrawalOut:
|
|
return reject_withdrawal(db, withdrawal_id, body.note)
|
|
|
|
|
|
def _summary(db: Session) -> SummaryOut:
|
|
users = db.scalar(select(func.count()).select_from(User)) or 0
|
|
nodes, online = node_counts(db)
|
|
return SummaryOut(
|
|
users=users,
|
|
nodes=displayed_population(nodes),
|
|
online=displayed_population(online),
|
|
offline=nodes - online,
|
|
authorized_amount=authorized_amount(db),
|
|
settled_points=settled_points(db),
|
|
pending_withdrawals=pending_withdrawal_amount(db),
|
|
)
|
|
|
|
|
|
def _token(user: User) -> TokenOut:
|
|
return TokenOut(
|
|
access_token=create_access_token(user),
|
|
role=user.role,
|
|
wallet_address=user.wallet_address,
|
|
username=user.username,
|
|
)
|
|
|
|
|
|
def _must_own_wallet(user: User, wallet_address: str) -> None:
|
|
if user.role != UserRole.ADMIN and user.wallet_address != wallet_address.strip():
|
|
raise AppError("只能操作自己的钱包", 403)
|
|
|
|
|
|
def _owned_node(db: Session, user: User, sn: str) -> ComputeNode:
|
|
node = get_node_by_sn(db, sn)
|
|
if user.role != UserRole.ADMIN and node.user_id != user.id:
|
|
raise AppError("无权查看该节点", 403)
|
|
return node
|
|
|
|
|
|
def _withdrawal_rows(db: Session) -> list[WithdrawalLog]:
|
|
pending_first = case((WithdrawalLog.status == WithdrawalStatus.PENDING, 0), else_=1)
|
|
return list(
|
|
db.scalars(
|
|
select(WithdrawalLog)
|
|
.options(selectinload(WithdrawalLog.user))
|
|
.order_by(pending_first, WithdrawalLog.id.desc())
|
|
).all()
|
|
)
|
|
|
|
|
|
def _datacenter_out(row: DatacenterApplication) -> DatacenterOut:
|
|
return DatacenterOut.model_validate(row)
|
|
|
|
|
|
def _datacenter_view(row: DatacenterApplication) -> DatacenterView:
|
|
return DatacenterView(
|
|
**_datacenter_out(row).model_dump(),
|
|
wallet_address=row.user.wallet_address,
|
|
)
|
|
|
|
|
|
def _order_out(order: LicenseOrder) -> OrderOut:
|
|
return OrderOut(
|
|
id=order.id,
|
|
user_id=order.user_id,
|
|
amount=order.amount,
|
|
asset_symbol=order.asset_symbol,
|
|
status=order.status,
|
|
tx_id=order.tx_id,
|
|
created_at=order.created_at,
|
|
reviewed_at=order.reviewed_at,
|
|
sn=order.node.sn if order.node is not None else None,
|
|
display_node_id=display_node_id(order.node.node_index) if order.node is not None else None,
|
|
)
|
|
|
|
|
|
def _order_view(order: LicenseOrder) -> OrderView:
|
|
return OrderView(**_order_out(order).model_dump(), wallet_address=order.user.wallet_address)
|
|
|
|
|
|
def _withdrawal_view(row: WithdrawalLog) -> WithdrawalView:
|
|
return WithdrawalView(
|
|
**WithdrawalOut.model_validate(row).model_dump(),
|
|
wallet_address=row.user.wallet_address,
|
|
)
|
|
|
|
|
|
def _admin_task_out(task: ComputeTask) -> AdminTaskOut:
|
|
node = task.node
|
|
return AdminTaskOut(
|
|
task_id=task.id,
|
|
task_type=task.task_type,
|
|
target_node_sn=node.sn if node is not None else "",
|
|
display_node_id=node.display_node_id if node is not None else 0,
|
|
status=task.status,
|
|
payload=task.payload or {},
|
|
result=task.result,
|
|
result_preview=result_preview(task.result),
|
|
dispatched_at=task.dispatched_at,
|
|
deadline_at=task.deadline_at,
|
|
responded_at=task.responded_at,
|
|
)
|
|
|
|
|
|
def _task_out(task: ComputeTask) -> TaskOut:
|
|
return TaskOut(
|
|
task_id=task.id,
|
|
task_type=task.task_type,
|
|
payload=task.payload,
|
|
status=task.status,
|
|
dispatched_at=task.dispatched_at,
|
|
deadline_at=task.deadline_at,
|
|
)
|