mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
216 lines
9.6 KiB
Python
216 lines
9.6 KiB
Python
"""Typed description of the single workflow run this container exists to execute.
|
|
|
|
The control plane hands the container exactly one of these (JSON in
|
|
OPENSWARM_RUN_SPEC, or a path in OPENSWARM_RUN_SPEC_FILE) and nothing else. Every
|
|
field is validated before the backend boots, so a malformed job dies in under a
|
|
second instead of burning a machine-minute discovering it.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import List, Literal, Optional
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.workflows.models import Workflow
|
|
|
|
SPEC_ENV = "OPENSWARM_RUN_SPEC"
|
|
SPEC_FILE_ENV = "OPENSWARM_RUN_SPEC_FILE"
|
|
|
|
# The one dashboard a cloud run has. Fixed rather than generated so the Electron window can be
|
|
# deep-linked at it before the backend has even booted, and so a workflow arriving with the
|
|
# laptop dashboard id it was authored against gets repointed at a dashboard that exists here.
|
|
CLOUD_RUN_DASHBOARD_ID = "cloud-run"
|
|
|
|
# Headroom the access token must still have on arrival. The control plane refreshes right before dispatch; anything thinner than this means its clock or its queue is broken, and we must not paper over that by refreshing ourselves.
|
|
MIN_TOKEN_LIFETIME = timedelta(minutes=2)
|
|
|
|
# A skill is prose plus the odd small script. These bounds keep a run spec from becoming a file
|
|
# transfer, and are enforced here so an oversized one dies before a machine is billed for it.
|
|
MAX_SKILLS = 60
|
|
MAX_SKILL_FILE_CHARS = 200_000
|
|
|
|
|
|
class InvalidRunSpec(ValueError):
|
|
"""The control plane handed us something we refuse to run."""
|
|
|
|
|
|
class ProviderCredential(BaseModel):
|
|
"""One already-refreshed provider credential, spendable but not rotatable.
|
|
|
|
`extra="forbid"` is the first of two walls keeping a refresh token out of this
|
|
container: a payload carrying `refreshToken` fails validation here and the run
|
|
dies loudly. See runner.router_credentials for the second wall and the why.
|
|
"""
|
|
|
|
model_config = ConfigDict(validate_assignment=True, extra="forbid")
|
|
|
|
provider: str = Field(min_length=1)
|
|
auth_type: Literal["oauth", "api_key"]
|
|
label: str = "OpenSwarm cloud run"
|
|
access_token: Optional[str] = None
|
|
api_key: Optional[str] = None
|
|
expires_at: Optional[datetime] = None
|
|
scope: Optional[str] = None
|
|
|
|
@model_validator(mode="after")
|
|
def p_require_matching_secret(self) -> "ProviderCredential":
|
|
if self.auth_type == "oauth":
|
|
if not self.access_token:
|
|
raise ValueError(f"credential for {self.provider!r} is oauth but carries no access_token")
|
|
if self.expires_at is None:
|
|
raise ValueError(f"credential for {self.provider!r} is oauth but carries no expires_at")
|
|
if self.api_key:
|
|
raise ValueError(f"credential for {self.provider!r} carries both an access_token and an api_key")
|
|
else:
|
|
if not self.api_key:
|
|
raise ValueError(f"credential for {self.provider!r} is api_key but carries no api_key")
|
|
if self.access_token:
|
|
raise ValueError(f"credential for {self.provider!r} carries both an access_token and an api_key")
|
|
return self
|
|
|
|
@typechecked
|
|
def remaining_lifetime(self, now: datetime) -> Optional[timedelta]:
|
|
"""How long this credential is still good for; None when it cannot expire."""
|
|
if self.expires_at is None:
|
|
return None
|
|
return self.expires_at.astimezone(timezone.utc) - now.astimezone(timezone.utc)
|
|
|
|
|
|
class CallbackTarget(BaseModel):
|
|
"""Where the run reports back. The token is a dedicated two-party secret, never a user credential."""
|
|
|
|
model_config = ConfigDict(validate_assignment=True, extra="forbid")
|
|
|
|
url: str = Field(min_length=1)
|
|
token: str = Field(min_length=1)
|
|
heartbeat_seconds: int = Field(default=30, ge=5, le=300)
|
|
# Where the run's files go. Named outright rather than derived from `url`, so a control plane
|
|
# that cannot accept files says so by leaving it out instead of being guessed at.
|
|
artifacts_url: Optional[str] = None
|
|
|
|
|
|
class SkillFile(BaseModel):
|
|
"""One file inside a skill folder. Text only: a skill is prose plus small scripts."""
|
|
|
|
model_config = ConfigDict(validate_assignment=True, extra="forbid")
|
|
|
|
path: str = Field(min_length=1, max_length=180)
|
|
text: str = Field(max_length=MAX_SKILL_FILE_CHARS)
|
|
|
|
@model_validator(mode="after")
|
|
def p_reject_escaping_path(self) -> "SkillFile":
|
|
parts = self.path.split("/")
|
|
if self.path.startswith("/") or "\\" in self.path or any(p in ("", ".", "..") for p in parts):
|
|
raise ValueError(f"skill file path {self.path!r} is not a plain relative path")
|
|
return self
|
|
|
|
|
|
class SkillPayload(BaseModel):
|
|
"""One of the user's skills, carried up so the agent has the same know-how it has at home.
|
|
|
|
Skills are the user's own writing, not credentials. Nothing in here is a token, and the
|
|
control plane never learns anything from it that it could spend.
|
|
"""
|
|
|
|
model_config = ConfigDict(validate_assignment=True, extra="forbid")
|
|
|
|
# Also the folder name under ~/.claude/skills, so it has to survive being a directory.
|
|
id: str = Field(min_length=1, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
|
files: List[SkillFile] = Field(min_length=1)
|
|
|
|
@model_validator(mode="after")
|
|
def p_require_skill_md(self) -> "SkillPayload":
|
|
if not any(file.path == "SKILL.md" for file in self.files):
|
|
raise ValueError(f"skill {self.id!r} has no SKILL.md, so nothing would ever load it")
|
|
return self
|
|
|
|
|
|
class McpServerNote(BaseModel):
|
|
"""A server the user has connected at home, named so the agent can say it cannot reach it.
|
|
|
|
Deliberately carries NO transport and NO secret. The user's MCP credentials (Slack session
|
|
cookies, Notion and GitHub access tokens, Google refresh tokens) stay on their laptop, so this
|
|
is a list of names and nothing else. Its whole job is to stop the agent quietly answering
|
|
"update my Notion" from general knowledge because it never knew Notion existed.
|
|
"""
|
|
|
|
model_config = ConfigDict(validate_assignment=True, extra="forbid")
|
|
|
|
name: str = Field(min_length=1, max_length=120)
|
|
|
|
|
|
class RunSpec(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True, extra="forbid")
|
|
|
|
run_id: str = Field(min_length=1)
|
|
workflow: Workflow
|
|
credentials: List[ProviderCredential] = Field(min_length=1)
|
|
callback: Optional[CallbackTarget] = None
|
|
# The user's skills, shipped so a cloud run is as capable as the same workflow at home.
|
|
skills: List[SkillPayload] = Field(default_factory=list, max_length=MAX_SKILLS)
|
|
# Names only. See McpServerNote for why there is no config here.
|
|
unavailable_mcp_servers: List[McpServerNote] = Field(default_factory=list, max_length=100)
|
|
# Hard wall-clock ceiling. Fly bills by machine-second, so an agent that wedges must cost a bounded amount.
|
|
max_run_seconds: int = Field(default=1800, ge=60, le=7200)
|
|
# Boot Electron under a virtual display so browser steps work. On by default: parity is the
|
|
# point of running in a container at all, and a workflow that never touches a browser is the
|
|
# exception that should have to say so. Costs a few seconds and a few hundred MB when on.
|
|
needs_browser: bool = True
|
|
|
|
@typechecked
|
|
def expired_credentials(self, now: datetime) -> List[ProviderCredential]:
|
|
"""Credentials too close to expiry to spend. The runner cannot refresh, so this is fatal, not a retry."""
|
|
stale: List[ProviderCredential] = []
|
|
for credential in self.credentials:
|
|
remaining = credential.remaining_lifetime(now)
|
|
if remaining is not None and remaining < MIN_TOKEN_LIFETIME:
|
|
stale.append(credential)
|
|
return stale
|
|
|
|
@typechecked
|
|
def workflow_for_disk(self) -> Workflow:
|
|
"""The workflow as this container should see it: one run, never a schedule.
|
|
|
|
A cloud-executed workflow arrives with its schedule still configured. Left
|
|
enabled, the container's own scheduler would fire it a second time inside
|
|
the box, so the timer is stripped here rather than trusted to stay off.
|
|
|
|
It also arrives pointing at whatever dashboard it was authored on, which does not
|
|
exist in this container; left alone, the first browser card would 404 looking for
|
|
it. Repointed at the one dashboard this run has.
|
|
"""
|
|
copy = self.workflow.model_copy(deep=True)
|
|
copy.dashboard_id = CLOUD_RUN_DASHBOARD_ID
|
|
copy.schedule.enabled = False
|
|
copy.deleted_at = None
|
|
copy.draft_steps = None
|
|
copy.next_run_at = None
|
|
return copy
|
|
|
|
|
|
@typechecked
|
|
def load_run_spec() -> RunSpec:
|
|
"""Parse the run spec from the environment, or raise InvalidRunSpec with a legible reason."""
|
|
raw = os.environ.get(SPEC_ENV, "").strip()
|
|
source = SPEC_ENV
|
|
if not raw:
|
|
path = os.environ.get(SPEC_FILE_ENV, "").strip()
|
|
if not path:
|
|
raise InvalidRunSpec(f"no run spec: set {SPEC_ENV} to JSON or {SPEC_FILE_ENV} to a file path")
|
|
source = f"{SPEC_FILE_ENV}={path}"
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
raw = handle.read()
|
|
except OSError as exc:
|
|
raise InvalidRunSpec(f"cannot read run spec from {source}: {exc}") from exc
|
|
|
|
try:
|
|
return RunSpec.model_validate_json(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise InvalidRunSpec(f"run spec from {source} is not valid JSON: {exc}") from exc
|
|
except ValueError as exc:
|
|
raise InvalidRunSpec(f"run spec from {source} is not a valid RunSpec: {exc}") from exc
|