mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
242 lines
14 KiB
Python
242 lines
14 KiB
Python
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
from typing import Optional, Literal, Any
|
|
from datetime import datetime
|
|
from uuid import uuid4
|
|
from backend.apps.settings.models import DEFAULT_MODEL
|
|
|
|
|
|
# Each "tier" in the permission chain: notify in app, fall through to text after N minutes if no response, then to call after a further N minutes/hours. Matches images 17 to 19 (Schedule edit). Order in the list = escalation order.
|
|
class PermissionTier(BaseModel):
|
|
kind: Literal["notify", "text", "call"] = "notify"
|
|
after_minutes: int = 0
|
|
phone: Optional[str] = None
|
|
|
|
|
|
class ScheduleConfig(BaseModel):
|
|
enabled: bool = False
|
|
# Bounds keep the scheduler from blowing up on malformed input. The FE clamps these too, but defense-in-depth: a misbehaving agent tool, an old JSON file, or a curl-wielding power user shouldn't be able to crash _next_fire_after by passing hour=99. The per-unit upper bound on repeat_every is clamped (not rejected) in _enforce_interval_bounds below, so only the floor lives on the Field.
|
|
repeat_every: int = Field(default=1, ge=1)
|
|
repeat_unit: Literal["minute", "hour", "day", "week", "month"] = "week"
|
|
on_days: list[int] = Field(default_factory=list)
|
|
hour: int = Field(default=9, ge=0, le=23)
|
|
minute: int = Field(default=0, ge=0, le=59)
|
|
# Monthly schedules can pin a day-of-month explicitly. None preserves the legacy "same day as the current reference" behavior for older records.
|
|
day_of_month: Optional[int] = Field(default=None, ge=1, le=31)
|
|
# When true, monthly schedules fire on the calendar's last day (28-31) regardless of day_of_month, so "end of month" survives short months.
|
|
last_day_of_month: bool = False
|
|
# IANA zone name (e.g. "America/Los_Angeles") or "local" for legacy records that predate explicit tz. storage._load_all_from_disk coerces "local" to the host zone in memory; we leave it on disk until the user's next save so backup/sync tools don't see spurious churn.
|
|
timezone: str = "local"
|
|
# Optional end conditions. None = forever / unbounded. Schedule auto- disables once either is satisfied; scheduler._tick zeroes out next_run_at and flips enabled=False so the UI reflects reality.
|
|
ends_at: Optional[datetime] = None
|
|
max_runs: Optional[int] = Field(default=None, ge=1)
|
|
runs_count: int = Field(default=0, ge=0)
|
|
|
|
@field_validator("on_days")
|
|
@classmethod
|
|
def _clean_on_days(cls, v: list[int]) -> list[int]:
|
|
# Backend uses JS-style weekday (Sun=0..Sat=6). Drop entries outside that range so a malformed PATCH can't trip the scheduler later, and dedupe while preserving order.
|
|
seen: set[int] = set()
|
|
out: list[int] = []
|
|
for d in v or []:
|
|
if isinstance(d, int) and 0 <= d <= 6 and d not in seen:
|
|
seen.add(d)
|
|
out.append(d)
|
|
return out
|
|
|
|
@model_validator(mode="after")
|
|
def _enforce_interval_bounds(self) -> "ScheduleConfig":
|
|
# Per-unit bounds, clamped rather than rejected so a stray value from an agent tool or old record can't crash the scheduler. The minute unit floors at 15 (no once-a-minute token-burning loop) and ceilings at 1440 (24h); every other unit keeps the original 365 ceiling.
|
|
if self.repeat_unit == "minute":
|
|
self.repeat_every = max(15, min(self.repeat_every, 1440))
|
|
else:
|
|
self.repeat_every = min(self.repeat_every, 365)
|
|
return self
|
|
|
|
|
|
class ActionsConfig(BaseModel):
|
|
prevent_unused: bool = False
|
|
freeze: bool = False
|
|
configured_sets: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class WorkflowStep(BaseModel):
|
|
id: str = Field(default_factory=lambda: uuid4().hex)
|
|
text: str = ""
|
|
# 3 to 6 word LLM-generated headline shown in the collapsed step row. The full prompt lives in `text`; this is the "at-a-glance" label.
|
|
label: Optional[str] = None
|
|
# Disabled steps stay in the list but the executor skips them, so a user can mute a step without losing its prompt. Defaults true for old records.
|
|
enabled: bool = True
|
|
|
|
|
|
def _empty_str_default() -> str:
|
|
return ""
|
|
|
|
|
|
class Workflow(BaseModel):
|
|
# validate_assignment is load-bearing for the PATCH /workflows/{id} path (workflows.py:update_workflow setattr's raw dicts from body.model_dump straight onto the cached Workflow). Without coercion the nested schedule/steps/actions/permissions fields become plain dicts in memory, and every downstream call; scheduler tick, executor.execute, subsequent PATCHes; crashes on `.enabled` / `.text`.
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
id: str = Field(default_factory=lambda: uuid4().hex)
|
|
title: str = "Untitled workflow"
|
|
description: str = ""
|
|
icon: str = ""
|
|
# User-chosen swatch (hex). None falls back to the id-hash color in the UI.
|
|
color: Optional[str] = None
|
|
# Soft-delete tombstone. Set = in Trash (hidden from lists + scheduler); restore nulls it, purge removes the record entirely.
|
|
deleted_at: Optional[datetime] = None
|
|
system_prompt: Optional[str] = None
|
|
use_synced_prompt: bool = True
|
|
# Agents may call this workflow via the InvokeWorkflow MCP tool (user opt-in per workflow on the Actions page).
|
|
exposed_as_tool: bool = False
|
|
steps: list[WorkflowStep] = Field(default_factory=list)
|
|
actions: ActionsConfig = Field(default_factory=ActionsConfig)
|
|
schedule: ScheduleConfig = Field(default_factory=ScheduleConfig)
|
|
# Where a SCHEDULED fire runs. "cloud" hands the timer to our servers outright, so this machine must never fire it nor roll next_run_at, or the same slot runs twice. Manual Run-now always stays local.
|
|
execution_target: Literal["device", "cloud"] = "device"
|
|
# The cloud's own id for our hosted copy. Only backend/apps/workflows/cloud writes these two, and only after the cloud has actually accepted the workflow.
|
|
cloud_workflow_id: Optional[str] = None
|
|
# Fingerprint of what we last pushed, so "the cloud is running an older version of this" is detectable instead of silent.
|
|
cloud_definition_signature: Optional[str] = None
|
|
permissions: list[PermissionTier] = Field(
|
|
default_factory=lambda: [PermissionTier(kind="notify")]
|
|
)
|
|
source_session_id: Optional[str] = None
|
|
# Tool names observed in the source chat when this workflow was generated. This preserves conversion context without pretending those calls map to generated workflow step ids. Explicit approval decisions still live in remembered_approvals and are the only values reused as permissions.
|
|
source_tools: list[str] = Field(default_factory=list)
|
|
dashboard_id: Optional[str] = None
|
|
model: str = DEFAULT_MODEL
|
|
mode: str = "agent"
|
|
provider: str = "anthropic"
|
|
created_at: datetime = Field(default_factory=datetime.now)
|
|
updated_at: datetime = Field(default_factory=datetime.now)
|
|
last_run_at: Optional[datetime] = None
|
|
last_run_status: Optional[Literal["success", "failure", "ran_late", "running", "skipped"]] = None
|
|
last_run_id: Optional[str] = None
|
|
next_run_at: Optional[datetime] = None
|
|
cost_cap_usd_monthly: Optional[float] = None
|
|
# Sticky session id for the Edit Agent embedded in the workflow card (Image #38, #48). Optional so older workflows don't fail validation on rehydrate.
|
|
edit_agent_session_id: Optional[str] = None
|
|
# Sticky session id for the embedded scheduling agent (the chat that turns "every Wednesday at 1pm" into a permission-gated tool call).
|
|
schedule_agent_session_id: Optional[str] = None
|
|
# Pending Edit-Agent draft of the steps. None = no draft in flight. Edits stage here and only land on `steps` when the user clicks Save; scheduled runs read `steps`, so a pending draft never affects a fire.
|
|
draft_steps: Optional[list[WorkflowStep]] = None
|
|
# Most recent Test Agent session for this workflow; read by ReadTestTranscript.
|
|
last_test_session_id: Optional[str] = None
|
|
# Tool permissions the user answered once and we reuse on later runs so an unattended scheduled fire doesn't stall waiting for someone to click. tool_name -> decision. Only ordinary "ask" tools land here; sensitive paths keep their own per-pattern trust and never auto-remember.
|
|
remembered_approvals: dict[str, Literal["allow", "deny"]] = Field(default_factory=dict)
|
|
# Behind-the-scenes record of which tools each step touched and whether each was permitted, keyed by stable step id (not index, so reorders don't scramble it). Auto-maintained on runs; enforcement stays workflow-level via remembered_approvals, this is the finer per-step picture.
|
|
step_tool_usage: dict[str, dict[str, bool]] = Field(default_factory=dict)
|
|
# False once the user explicitly sets a title; True means the backend may overwrite the title via auto-naming when steps are added/changed.
|
|
auto_named: bool = False
|
|
# True for a brand-new "+ New" workflow that the user is still building in the Edit Agent and hasn't saved yet. The Workflows hub hides these from the scheduled/unscheduled lists until the first commit clears the flag, so an in-progress build doesn't litter the sidebar.
|
|
unsaved: bool = False
|
|
# Stable signature of the steps last validated by a test run (or seeded at chat conversion). The FE compares it against the current steps before scheduling: a mismatch means "edited since you last approved tools" and triggers the test-first warning. Computed FE-side so there's one algorithm.
|
|
tested_signature: Optional[str] = None
|
|
|
|
|
|
class WorkflowRun(BaseModel):
|
|
id: str = Field(default_factory=lambda: uuid4().hex)
|
|
workflow_id: str
|
|
status: Literal["running", "success", "failure", "ran_late", "skipped"] = "running"
|
|
scheduled_for: Optional[datetime] = None
|
|
started_at: datetime = Field(default_factory=datetime.now)
|
|
finished_at: Optional[datetime] = None
|
|
session_id: Optional[str] = None
|
|
error: Optional[str] = None
|
|
cost_usd: float = 0.0
|
|
triggered_by: Literal["schedule", "manual", "retry"] = "schedule"
|
|
# Last tool-call label observed on the underlying agent session while the workflow is running. Surfaced under the active step in RunningView (Image #40) so the user can tell the run is still making progress.
|
|
last_tool_label: Optional[str] = None
|
|
# Currently-executing step index (0-based). Executor bumps this each time it dispatches a step prompt and broadcasts the run. RunningView uses this for the disc statuses; estimate fallback only when null.
|
|
active_step_idx: Optional[int] = None
|
|
# True while the user has paused the in-flight agent turn (same mechanic as the chat's stop/resume). Rides the workflow:run broadcast so the card shows the paused state even when the live chat isn't open.
|
|
paused: bool = False
|
|
|
|
|
|
class MissedRun(BaseModel):
|
|
# A single scheduled fire that elapsed while OpenSwarm was closed. Captured at startup and surfaced in the launch-time review card; leaves this store only when the user runs it (becomes a ran_late run) or dismisses it (becomes a skipped run). scheduled_for is the instant it should have fired.
|
|
id: str = Field(default_factory=lambda: uuid4().hex)
|
|
workflow_id: str
|
|
scheduled_for: datetime
|
|
created_at: datetime = Field(default_factory=datetime.now)
|
|
|
|
|
|
class WorkflowCreate(BaseModel):
|
|
title: str = "Untitled workflow"
|
|
auto_named: bool = True
|
|
# Only the "+ New" build flow sets this; every other create path is a deliberate save and stays visible immediately.
|
|
unsaved: bool = False
|
|
description: str = ""
|
|
icon: str = ""
|
|
color: Optional[str] = None
|
|
system_prompt: Optional[str] = None
|
|
use_synced_prompt: bool = True
|
|
# Agents may call this workflow via the InvokeWorkflow MCP tool (user opt-in per workflow on the Actions page).
|
|
exposed_as_tool: bool = False
|
|
steps: list[WorkflowStep] = Field(default_factory=list)
|
|
actions: ActionsConfig = Field(default_factory=ActionsConfig)
|
|
schedule: ScheduleConfig = Field(default_factory=ScheduleConfig)
|
|
permissions: Optional[list[PermissionTier]] = None
|
|
source_session_id: Optional[str] = None
|
|
dashboard_id: Optional[str] = None
|
|
model: Optional[str] = None
|
|
mode: Optional[str] = None
|
|
provider: Optional[str] = None
|
|
cost_cap_usd_monthly: Optional[float] = None
|
|
tested_signature: Optional[str] = None
|
|
# The FE already named + described + labeled this at preview time; skip the backend aux call so we don't double-spend or change the title under the user.
|
|
metadata_generated: bool = False
|
|
|
|
|
|
class GenerateMetadataRequest(BaseModel):
|
|
steps: list[WorkflowStep] = Field(default_factory=list)
|
|
model: Optional[str] = None
|
|
|
|
|
|
class GenerateMetadataResponse(BaseModel):
|
|
title: str = ""
|
|
description: str = ""
|
|
step_labels: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class WorkflowUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
auto_named: Optional[bool] = None
|
|
# Revealing a compose draft (Save, or auto on first chat message) flips this to False so the hub stops hiding it. Without it here the PATCH was a no-op.
|
|
unsaved: Optional[bool] = None
|
|
description: Optional[str] = None
|
|
icon: Optional[str] = None
|
|
color: Optional[str] = None
|
|
system_prompt: Optional[str] = None
|
|
use_synced_prompt: Optional[bool] = None
|
|
exposed_as_tool: Optional[bool] = None
|
|
steps: Optional[list[WorkflowStep]] = None
|
|
actions: Optional[ActionsConfig] = None
|
|
schedule: Optional[ScheduleConfig] = None
|
|
permissions: Optional[list[PermissionTier]] = None
|
|
model: Optional[str] = None
|
|
mode: Optional[str] = None
|
|
provider: Optional[str] = None
|
|
cost_cap_usd_monthly: Optional[float] = None
|
|
remembered_approvals: Optional[dict[str, Literal["allow", "deny"]]] = None
|
|
step_tool_usage: Optional[dict[str, dict[str, bool]]] = None
|
|
|
|
|
|
class MissedRunAction(BaseModel):
|
|
ids: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class AskRunBody(BaseModel):
|
|
# Answer a chat question with a finished run's transcript folded in as context. run_id picks the run's session to pull in; prompt is the user's question.
|
|
run_id: str
|
|
prompt: str
|
|
mode: Optional[str] = None
|
|
model: Optional[str] = None
|
|
|
|
|
|
class DraftCommitBody(BaseModel):
|
|
# The model the user settled on in the Edit Agent picker, applied to the workflow's run model only on Save (save-gated; Discard drops it).
|
|
model: Optional[str] = None
|
|
# Keep the edit-agent session alive across the commit. The build flow auto-commits steps as the agent adds them but must NOT close the chat, the user keeps talking in the same conversation after it becomes saved.
|
|
keep_session: bool = False
|