mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-24 02:24:52 +02:00
* [aidan] feat/workflow-auto-naming: auto-generate workflow titles from steps Generate a title + description from a workflow's steps (one aux call, reused for step labels) whenever it is still auto_named, so a workflow built in the Edit Agent names itself on commit instead of staying "New workflow". A manual rename sets auto_named=False and is never overwritten. Stream the aux call (non-streaming drops content on some 9router lanes) and fall back to a step-derived title when the model is unavailable. * [aidan] feat/workflows: hide unsaved new workflows until first save A brand-new "+ New" workflow is created with unsaved=true and kept out of the hub's scheduled/unscheduled lists while the user is still building it in the Edit Agent. The first commit (Save) clears the flag and the workflow appears. Every other create path stays visible immediately. * [aidan] ux/workflows: remove redundant save workflow button The Edit Agent already has Discard/Save controls in its strip, so the header "Save Workflow" button was a duplicate save path. Remove it and its pulse/edit-session-id wiring; the model/time subtitle stays. * [aidan] ux/workflows: animate title on auto-rename Wrap the workflow card title in the same Typewriter the chat card uses, so when the auto-generated name replaces the placeholder after Save it retypes letter-by-letter. Gated on a real (non-placeholder) title so it never animates on mount or for already-named workflows. * [aidan] ux/workflows: animate sidebar title on auto-rename Wrap the calendar hub's sidebar row title in the same Typewriter the workflow card uses, so a title that auto-renames retypes letter-by-letter in the sidebar too. Extract the placeholder/isRealTitle guard into the shared workflowVisuals so the card and sidebar stay in sync.
213 lines
9.8 KiB
Python
213 lines
9.8 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
|
|
|
|
|
|
# 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)
|
|
# 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"
|
|
on_missed: Literal["skip", "run_once", "run_all"] = "skip"
|
|
# 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
|
|
|
|
|
|
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 = ""
|
|
system_prompt: Optional[str] = None
|
|
use_synced_prompt: bool = True
|
|
steps: list[WorkflowStep] = Field(default_factory=list)
|
|
actions: ActionsConfig = Field(default_factory=ActionsConfig)
|
|
schedule: ScheduleConfig = Field(default_factory=ScheduleConfig)
|
|
permissions: list[PermissionTier] = Field(
|
|
default_factory=lambda: [PermissionTier(kind="notify")]
|
|
)
|
|
source_session_id: Optional[str] = None
|
|
dashboard_id: Optional[str] = None
|
|
model: str = "sonnet"
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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 = ""
|
|
system_prompt: Optional[str] = None
|
|
use_synced_prompt: bool = True
|
|
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
|
|
|
|
|
|
class WorkflowUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
auto_named: Optional[bool] = None
|
|
description: Optional[str] = None
|
|
icon: Optional[str] = None
|
|
system_prompt: Optional[str] = None
|
|
use_synced_prompt: 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
|