mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 11:42:23 +02:00
146 lines
5.3 KiB
Python
146 lines
5.3 KiB
Python
"""HTTP surface for running a workflow on our servers instead of this machine.
|
|
|
|
Its own SubApp so the already-large workflows.py does not grow. Prefix:
|
|
/api/cloud_workflows. The local half of the handover (which timer is armed)
|
|
lives in cloud/handover.py; nothing here is the authority on entitlement, the
|
|
cloud re-decides that at create and again at dispatch.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
from typing import List, Literal, Optional, Union
|
|
|
|
from fastapi import HTTPException
|
|
from pydantic import BaseModel, ConfigDict
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.workflows import storage
|
|
from backend.apps.nine_router.lent_credential_refresh import lent_credential_loop
|
|
from backend.apps.workflows.cloud import client as cloud
|
|
from backend.apps.workflows.cloud.handover import TargetOutcome, hand_to_cloud, take_back
|
|
from backend.apps.workflows.cloud.run_files import LocalRunFile, described, downloads_root, fetch_missing
|
|
from backend.apps.workflows.cloud.status import CloudStatus, compute_status, epoch_to_datetime
|
|
from backend.apps.workflows.models import Workflow
|
|
from backend.config.Apps import SubApp
|
|
|
|
|
|
@asynccontextmanager
|
|
async def cloud_workflows_lifespan():
|
|
# Lending a credential upward strips this device's ability to renew it, so something has to
|
|
# ask the cloud for a fresh one before the old one dies. Without this, turning on a cloud
|
|
# workflow quietly stops local agents a few hours later.
|
|
task = asyncio.create_task(lent_credential_loop())
|
|
try:
|
|
yield
|
|
finally:
|
|
task.cancel()
|
|
|
|
|
|
cloud_workflows = SubApp("cloud_workflows", cloud_workflows_lifespan)
|
|
|
|
|
|
class TargetRequest(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
# The whole desired state, not a delta: where the schedule runs and whether it runs at all.
|
|
target: Literal["device", "cloud"]
|
|
enabled: bool
|
|
|
|
|
|
class CloudRunRow(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
id: str
|
|
status: str
|
|
started_at: Optional[datetime] = None
|
|
finished_at: Optional[datetime] = None
|
|
error: Optional[str] = None
|
|
answer: Optional[str] = None
|
|
notices: List[str] = []
|
|
cost_usd: Optional[float] = None
|
|
files: List[LocalRunFile] = []
|
|
|
|
|
|
class CloudRunsReady(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
state: Literal["ready"] = "ready"
|
|
runs: List[CloudRunRow] = []
|
|
# Where this machine puts a cloud run's files, so the UI can say it out loud even when a run
|
|
# has none yet. A folder the user is told about is a folder they can find later.
|
|
files_folder: str = ""
|
|
|
|
|
|
class CloudRunsUnavailable(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
# Same rule as the status route: not knowing is its own answer, and it shows no runs rather than "no runs".
|
|
state: Literal["signed_out", "unknown"]
|
|
detail: Optional[str] = None
|
|
|
|
|
|
CloudRunsResponse = Union[CloudRunsReady, CloudRunsUnavailable]
|
|
|
|
|
|
@typechecked
|
|
def p_workflow(workflow_id: str) -> Workflow:
|
|
wf = storage.get_workflow(workflow_id)
|
|
if not wf or wf.deleted_at is not None:
|
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
return wf
|
|
|
|
|
|
@cloud_workflows.router.get("/{workflow_id}/status")
|
|
async def workflow_cloud_status(workflow_id: str) -> CloudStatus:
|
|
return await compute_status(p_workflow(workflow_id))
|
|
|
|
|
|
@cloud_workflows.router.post("/{workflow_id}/target")
|
|
async def set_workflow_target(workflow_id: str, body: TargetRequest) -> TargetOutcome:
|
|
wf = p_workflow(workflow_id)
|
|
if body.target == "cloud":
|
|
return await hand_to_cloud(wf, body.enabled)
|
|
return await take_back(wf, body.enabled)
|
|
|
|
|
|
@cloud_workflows.router.get("/{workflow_id}/runs")
|
|
async def workflow_cloud_runs(workflow_id: str) -> CloudRunsResponse:
|
|
wf = p_workflow(workflow_id)
|
|
if not wf.cloud_workflow_id:
|
|
return CloudRunsReady(runs=[], files_folder=downloads_root())
|
|
try:
|
|
runs = await cloud.list_runs(wf.cloud_workflow_id)
|
|
except cloud.SignedOut:
|
|
return CloudRunsUnavailable(state="signed_out")
|
|
except cloud.CloudRefused as exc:
|
|
# A 404 here is the hosted copy being gone, which is an empty history, not a broken one.
|
|
if exc.status == 404:
|
|
return CloudRunsReady(runs=[], files_folder=downloads_root())
|
|
return CloudRunsUnavailable(state="unknown", detail=exc.message)
|
|
except cloud.CloudUnreachable as exc:
|
|
return CloudRunsUnavailable(state="unknown", detail=exc.detail)
|
|
|
|
# Answered now, files fetched behind it. A 20MB attachment must not hold up the history the
|
|
# user asked for, and a run's files appear a moment later without them doing anything.
|
|
asyncio.create_task(fetch_missing(wf.cloud_workflow_id, wf, runs))
|
|
|
|
return CloudRunsReady(
|
|
files_folder=downloads_root(),
|
|
runs=[
|
|
CloudRunRow(
|
|
id=r.id,
|
|
status=r.status,
|
|
started_at=epoch_to_datetime(r.started_at),
|
|
finished_at=epoch_to_datetime(r.finished_at),
|
|
error=r.error,
|
|
answer=r.answer,
|
|
notices=r.notices,
|
|
cost_usd=r.cost_usd,
|
|
files=described(wf, r),
|
|
)
|
|
for r in runs
|
|
],
|
|
)
|