mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-27 12:04:49 +02:00
[aidan] feat/workflows: launch-time scheduling UX and workflow-card polish (#101)
* [aidan] feat/schedule-list: lazy-load list view via scroll sentinel * [aidan] feat/missed-runs: launch toast with per-workflow counts and pan-to-card * [aidan] fix/dashboard-tethers: keep watching line anchored on canvas zoom * [aidan] feat/scheduled-tasks: review missed runs at launch instead of auto-firing on_missed * [aidan] refactor/workflow-cards: use radius and status design tokens, polish card chrome * [aidan] ux/agent-card: keep convert-to-workflow visible during runs with mid-turn toast * [aidan] ux/mcp-bubble: drop redundant verb label when a workflow label is shown * [aidan] chore/backend: remove stale explanatory comments * [aidan] fix/workflows-hub: load workflows on hub mount so calendar fills at launch * [aidan] feat/workflows: generate title, description, step labels at convert time * [aidan] fix/tidy-layout: include workflows hub in tidy and fit-to-view * [aidan] feat/schedule-list: window long list via measured-height virtualizer * [aidan] ux/workflows-hub: remove time-saved badge from calendar header * [aidan] fix/types: add missing semantic-type labels and drop stray fade arg
This commit is contained in:
@@ -14,6 +14,9 @@ from backend.apps.workflows.models import (
|
||||
WorkflowRun,
|
||||
WorkflowStep,
|
||||
DraftCommitBody,
|
||||
MissedRunAction,
|
||||
GenerateMetadataRequest,
|
||||
GenerateMetadataResponse,
|
||||
)
|
||||
from backend.apps.workflows import storage, scheduler, executor, audit, escalation
|
||||
|
||||
@@ -264,22 +267,25 @@ async def create_workflow(body: WorkflowCreate):
|
||||
# leaving stale session names ("Inbox check") as titles. Step labels
|
||||
# are the 3-6 word at-a-glance headlines surfaced in StepList; without
|
||||
# them the UI falls back to truncated raw prompts.
|
||||
try:
|
||||
title, description, labels = await _generate_workflow_metadata(wf)
|
||||
# Respect a user-supplied title (auto_named=False); only auto-fill the
|
||||
# name + description while the workflow is still auto-named. Labels are
|
||||
# always safe to fill since they don't override a user's title.
|
||||
if wf.auto_named:
|
||||
if title:
|
||||
wf.title = title
|
||||
if description:
|
||||
wf.description = description
|
||||
if labels and len(labels) == len(wf.steps):
|
||||
for i, lab in enumerate(labels):
|
||||
if lab:
|
||||
wf.steps[i].label = lab
|
||||
except Exception:
|
||||
pass
|
||||
# When the FE already generated metadata at preview time it ships the title,
|
||||
# description, and per-step labels on the body, so we skip the aux call here.
|
||||
if not body.metadata_generated:
|
||||
try:
|
||||
title, description, labels = await _generate_workflow_metadata(wf)
|
||||
# Respect a user-supplied title (auto_named=False); only auto-fill the
|
||||
# name + description while the workflow is still auto-named. Labels are
|
||||
# always safe to fill since they don't override a user's title.
|
||||
if wf.auto_named:
|
||||
if title:
|
||||
wf.title = title
|
||||
if description:
|
||||
wf.description = description
|
||||
if labels and len(labels) == len(wf.steps):
|
||||
for i, lab in enumerate(labels):
|
||||
if lab:
|
||||
wf.steps[i].label = lab
|
||||
except Exception:
|
||||
pass
|
||||
storage.save_workflow(wf)
|
||||
scheduler.kick()
|
||||
enriched = _enriched(wf)
|
||||
@@ -294,6 +300,15 @@ async def create_workflow(body: WorkflowCreate):
|
||||
return enriched
|
||||
|
||||
|
||||
@workflows.router.post("/generate-metadata")
|
||||
async def generate_workflow_metadata(body: GenerateMetadataRequest) -> GenerateMetadataResponse:
|
||||
# Preview-time naming for the convert-to-workflow draft. Generates without
|
||||
# persisting so the card can show a real title before the user saves.
|
||||
wf = Workflow(steps=body.steps, model=body.model or "sonnet")
|
||||
title, description, labels = await _generate_workflow_metadata(wf)
|
||||
return GenerateMetadataResponse(title=title, description=description, step_labels=labels)
|
||||
|
||||
|
||||
async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str]]:
|
||||
"""Single aux-model call returning (title, description, step_labels).
|
||||
|
||||
@@ -593,6 +608,66 @@ async def list_all_runs(limit: int = 200):
|
||||
return {"runs": [r.model_dump(mode="json") for r in runs]}
|
||||
|
||||
|
||||
@workflows.router.get("/missed")
|
||||
async def list_missed_runs(limit: int = 50):
|
||||
"""Pending fires that elapsed while the app was closed, newest-first.
|
||||
Backs the launch-time review card."""
|
||||
missed = sorted(storage.list_missed(), key=lambda m: m.scheduled_for, reverse=True)
|
||||
out: list[dict] = []
|
||||
for m in missed[:limit]:
|
||||
wf = storage.get_workflow(m.workflow_id)
|
||||
if not wf:
|
||||
continue
|
||||
out.append({
|
||||
"id": m.id,
|
||||
"workflow_id": m.workflow_id,
|
||||
"workflow_title": wf.title,
|
||||
"workflow_icon": wf.icon,
|
||||
"scheduled_for": m.scheduled_for.isoformat() if isinstance(m.scheduled_for, datetime) else m.scheduled_for,
|
||||
})
|
||||
return {"missed": out}
|
||||
|
||||
|
||||
@workflows.router.post("/missed/run")
|
||||
async def run_missed_runs(body: MissedRunAction):
|
||||
"""Run the selected missed fires now. Each lands in History as ran_late.
|
||||
Fires of the same workflow run sequentially (the executor blocks
|
||||
concurrent runs of one workflow)."""
|
||||
wanted = set(body.ids)
|
||||
selected = [m for m in storage.list_missed() if m.id in wanted]
|
||||
if not selected:
|
||||
return {"started": 0}
|
||||
storage.remove_missed([m.id for m in selected])
|
||||
by_wf: dict[str, list[datetime]] = {}
|
||||
for m in sorted(selected, key=lambda m: m.scheduled_for):
|
||||
by_wf.setdefault(m.workflow_id, []).append(m.scheduled_for)
|
||||
started = 0
|
||||
for wid, fors in by_wf.items():
|
||||
wf = storage.get_workflow(wid)
|
||||
if not wf:
|
||||
continue
|
||||
started += len(fors)
|
||||
asyncio.create_task(scheduler.run_missed_sequence(wf, fors))
|
||||
return {"started": started}
|
||||
|
||||
|
||||
@workflows.router.post("/missed/dismiss")
|
||||
async def dismiss_missed_runs(body: MissedRunAction):
|
||||
"""Drop the selected missed fires, logging each as a skipped run so the
|
||||
workflow's history still shows it happened."""
|
||||
wanted = set(body.ids)
|
||||
selected = [m for m in storage.list_missed() if m.id in wanted]
|
||||
storage.remove_missed([m.id for m in selected])
|
||||
dismissed = 0
|
||||
for m in selected:
|
||||
wf = storage.get_workflow(m.workflow_id)
|
||||
if not wf:
|
||||
continue
|
||||
scheduler.record_skipped(wf, m.scheduled_for, "You dismissed this missed run")
|
||||
dismissed += 1
|
||||
return {"dismissed": dismissed}
|
||||
|
||||
|
||||
@workflows.router.get("/calendar")
|
||||
async def list_calendar_events(
|
||||
from_: str = Query(..., alias="from"),
|
||||
|
||||
Reference in New Issue
Block a user