feat(my-deepagent): v0.4 — workflow generator UI + hot-reload + UX polish
브라우저에서 YAML 안 쓰고도 새 워크플로우 템플릿 만들기 + 즉시 등록. + /new.html / index.html / new-workflow.html / runs.html / conversation.html 의 nav·copy·empty-state 정비. A. /new.html UX: - 제목 "새 Run" → "워크플로우 실행 (고급)" - 상단 info-box: "자유 대화는 여기가 아닙니다 → 메인 페이지" - 모든 필드에 한 줄 hint - Persona 오버라이드 <details> 접힘 B. Nav 재정렬 (5 페이지): - "대화" nav-primary, 나머지 nav-secondary (작고 dim) C. 메인 안내 + CSS: - 메인 / 에 "👋 my-deepagent" info-box 추가 - .info-box / .nav-primary / .nav-secondary / .wf-* 신규 스타일 D. Workflow hot-reload: - api/deps.py get_workflows 가 매 요청 mtime 튜플 검사 후 변경 시 reload - lifespan 도 user dir 포함하도록 _load_workflows_combined E. Workflow generator: - POST /api/workflows: CreateWorkflowRequest → WorkflowTemplate validate → <data_dir>/workflows/<name>@<version>.yaml 저장. 중복 409, validation 422. - static/new-workflow.html: 기본 정보 / Roles / Phases / YAML preview - app.js bootstrapWorkflowGenerator: capability chip 토글, role select 동적, 실시간 YAML preview, XSS 정책 유지 테스트 (test_workflow_generator.py, 7 신규): - 페이지 200 + 마크업 - POST happy / 422 (empty roles) / 422 (unknown role) / 409 (dup) - GET hot-reload after POST - GET hot-reload after external file drop 게이트: - ruff / format / mypy: PASS (142 source files) - pytest -q --ignore=tests/integration/test_e2e_workflow.py --ignore=tests/integration/test_openrouter_smoke.py: 709 passed (+7 신규) - 라이브 smoke: / / new.html / new-workflow.html 모두 200, screenshot OK Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,8 @@ from ..config import Config, load_config
|
||||
from ..persistence.checkpointer import get_checkpointer_ctx
|
||||
from ..persistence.db import Database
|
||||
from ..persona import load_personas_from_dir
|
||||
from ..workflow import WorkflowTemplate, load_workflow_yaml
|
||||
from ..user_dirs import load_combined_workflows
|
||||
from ..workflow import WorkflowTemplate
|
||||
from .routes import budget as budget_routes
|
||||
from .routes import personas as personas_routes
|
||||
from .routes import runs as runs_routes
|
||||
@@ -33,24 +34,11 @@ _STATIC_ROOT = Path(__file__).resolve().parents[3] / "static"
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_seed_workflows() -> list[tuple[Path, WorkflowTemplate]]:
|
||||
"""Return (path, WorkflowTemplate) for every YAML in docs/schemas/workflows/.
|
||||
|
||||
Malformed YAMLs are logged and skipped — the API should still come up
|
||||
cleanly even if one seed is broken.
|
||||
"""
|
||||
wf_dir = _DOCS_SCHEMAS / "workflows"
|
||||
if not wf_dir.is_dir():
|
||||
return []
|
||||
out: list[tuple[Path, WorkflowTemplate]] = []
|
||||
for p in sorted(wf_dir.glob("*.yaml")):
|
||||
try:
|
||||
tpl = load_workflow_yaml(p)
|
||||
except Exception as e:
|
||||
_LOG.warning("skipping malformed workflow yaml %s: %s", p, e)
|
||||
continue
|
||||
out.append((p, tpl))
|
||||
return out
|
||||
def _load_workflows_combined(config: Config) -> list[tuple[Path, WorkflowTemplate]]:
|
||||
"""Seed + user workflows. Malformed YAMLs are logged + skipped — the
|
||||
API still comes up cleanly even if one file is broken. Per-request
|
||||
hot-reload (`deps.get_workflows`) reuses the same loader."""
|
||||
return load_combined_workflows(config, _DOCS_SCHEMAS / "workflows")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -70,7 +58,9 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
app.state.config = config
|
||||
app.state.db = db
|
||||
app.state.personas = load_personas_from_dir(_DOCS_SCHEMAS / "personas")
|
||||
app.state.workflows = _load_seed_workflows()
|
||||
app.state.workflows = _load_workflows_combined(config)
|
||||
# Hot-reload signature — `deps.get_workflows` re-checks per request.
|
||||
app.state.workflows_sig = None
|
||||
saver_ctx = get_checkpointer_ctx(config.database_url)
|
||||
try:
|
||||
# AsyncPostgresSaver.from_conn_string only works for postgres; for sqlite
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
Pulls singletons stashed in `app.state` by the lifespan handler. Database is
|
||||
created ONCE per uvicorn process; per-request creation would defeat
|
||||
connection pooling.
|
||||
|
||||
Workflows are different — they live in YAML files that the user can edit /
|
||||
create at runtime via the workflow generator UI. `get_workflows` does a
|
||||
cheap mtime check on every call and reloads when any file in the seed or
|
||||
user workflow directory has changed. No file watcher / inotify needed —
|
||||
the directories are tiny (≤ dozens of files) and stat is cheap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,6 +20,7 @@ from fastapi import Request
|
||||
|
||||
from ..config import Config
|
||||
from ..persistence.db import Database
|
||||
from ..user_dirs import load_combined_workflows, user_workflows_dir
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..persona import Persona
|
||||
@@ -36,9 +43,41 @@ def get_personas(request: Request) -> list[Persona]:
|
||||
return request.app.state.personas # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def _workflow_dir_signature(config: Config) -> tuple[tuple[str, float], ...]:
|
||||
"""Cheap mtime-tuple fingerprint of all YAMLs in seed + user dirs.
|
||||
|
||||
Two stat calls per file; the fingerprint changes when any file is
|
||||
created / modified / deleted. Used as the cache key for
|
||||
:func:`get_workflows` so the API picks up new templates without a
|
||||
process restart.
|
||||
"""
|
||||
sig: list[tuple[str, float]] = []
|
||||
for d in (_DOCS_SCHEMAS / "workflows", user_workflows_dir(config)):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for p in sorted(d.glob("*.yaml")):
|
||||
try:
|
||||
sig.append((str(p), p.stat().st_mtime))
|
||||
except OSError:
|
||||
continue
|
||||
return tuple(sig)
|
||||
|
||||
|
||||
def get_workflows(request: Request) -> list[tuple[Path, WorkflowTemplate]]:
|
||||
"""Return a list of (yaml_path, WorkflowTemplate) for all seed workflows."""
|
||||
return request.app.state.workflows # type: ignore[no-any-return]
|
||||
"""Return (path, template) list with mtime-based hot-reload.
|
||||
|
||||
On every request, computes the mtime fingerprint of the workflow dirs.
|
||||
If it differs from the cached signature, calls
|
||||
:func:`load_combined_workflows` again to pick up new / edited files.
|
||||
"""
|
||||
app = request.app
|
||||
config: Config = app.state.config
|
||||
current_sig = _workflow_dir_signature(config)
|
||||
cached_sig: tuple[tuple[str, float], ...] | None = getattr(app.state, "workflows_sig", None)
|
||||
if cached_sig != current_sig:
|
||||
app.state.workflows = load_combined_workflows(config, _DOCS_SCHEMAS / "workflows")
|
||||
app.state.workflows_sig = current_sig
|
||||
return app.state.workflows # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def seed_root() -> Path:
|
||||
|
||||
@@ -128,6 +128,60 @@ class WorkflowSummary(_Strict):
|
||||
phases: list[WorkflowPhaseSummary]
|
||||
|
||||
|
||||
# v0.4 — workflow generator UI (POST /api/workflows)
|
||||
|
||||
|
||||
class WorkflowRoleSpec(_Strict):
|
||||
"""Input shape for one role inside a CreateWorkflowRequest."""
|
||||
|
||||
id: str = Field(min_length=1, pattern=r"^[a-z][a-z0-9_]*$")
|
||||
required_capabilities: list[str] = Field(min_length=1)
|
||||
preferred_backends: list[str] = Field(default_factory=list)
|
||||
fallback_personas: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorkflowArtifactSpec(_Strict):
|
||||
"""Input shape for one phase's expected_artifact (optional)."""
|
||||
|
||||
path: str = Field(min_length=1)
|
||||
# YAML key is `schema`; pydantic attribute aliased to avoid BaseModel.schema clash
|
||||
schema_id: str = Field(min_length=1, alias="schema")
|
||||
|
||||
|
||||
class WorkflowPhaseSpec(_Strict):
|
||||
"""Input shape for one phase inside a CreateWorkflowRequest."""
|
||||
|
||||
key: str = Field(min_length=1, pattern=r"^[a-z][a-z0-9_]*$")
|
||||
title: str = Field(min_length=1)
|
||||
risk: str = Field(min_length=1) # low|medium|high — validated by WorkflowTemplate
|
||||
role: str = Field(min_length=1)
|
||||
instructions: str = Field(min_length=10)
|
||||
expected_artifact: WorkflowArtifactSpec | None = None
|
||||
gates: list[str] = Field(default_factory=list)
|
||||
timeout_seconds: int | None = Field(default=None, ge=1)
|
||||
max_budget_usd: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class CreateWorkflowRequest(_Strict):
|
||||
"""Body for POST /api/workflows — saves a new template YAML on disk."""
|
||||
|
||||
name: str = Field(min_length=1)
|
||||
version: int = Field(ge=1)
|
||||
description: str | None = None
|
||||
roles: list[WorkflowRoleSpec] = Field(min_length=1)
|
||||
phases: list[WorkflowPhaseSpec] = Field(min_length=1)
|
||||
default_gates: list[str] = Field(default_factory=list)
|
||||
max_total_budget_usd: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class CreateWorkflowResponse(_Strict):
|
||||
"""Returned by POST /api/workflows."""
|
||||
|
||||
path: str # absolute path of the saved YAML
|
||||
name: str
|
||||
version: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/budget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
"""GET /api/workflows — list seed workflow templates."""
|
||||
"""GET /api/workflows — list seed + user templates (hot-reloaded).
|
||||
|
||||
v0.4: POST /api/workflows persists a new template YAML under
|
||||
``<config.data_dir>/workflows/<name>@<version>.yaml`` so the workflow
|
||||
generator UI can create templates without leaving the browser.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ...config import Config
|
||||
from ...user_dirs import user_workflows_dir
|
||||
from ...workflow import WorkflowTemplate
|
||||
from ..deps import get_workflows, seed_root
|
||||
from ..models import WorkflowPhaseSummary, WorkflowRoleSummary, WorkflowSummary
|
||||
from ..deps import get_config, get_workflows, seed_root
|
||||
from ..models import (
|
||||
CreateWorkflowRequest,
|
||||
CreateWorkflowResponse,
|
||||
WorkflowPhaseSummary,
|
||||
WorkflowRoleSummary,
|
||||
WorkflowSummary,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
WorkflowsDep = Annotated[list[tuple[Path, WorkflowTemplate]], Depends(get_workflows)]
|
||||
ConfigDep = Annotated[Config, Depends(get_config)]
|
||||
|
||||
|
||||
@router.get("", response_model=list[WorkflowSummary])
|
||||
@@ -50,3 +66,57 @@ async def list_workflows(workflows: WorkflowsDep) -> list[WorkflowSummary]:
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=CreateWorkflowResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_workflow(body: CreateWorkflowRequest, config: ConfigDep) -> CreateWorkflowResponse:
|
||||
"""Persist a new WorkflowTemplate YAML under the user workflows dir.
|
||||
|
||||
Pipeline:
|
||||
1. Convert request → dict (frontmatter aliases preserved: ``schema``
|
||||
not ``schema_id`` so the file is round-tripped by
|
||||
:func:`load_workflow_yaml`).
|
||||
2. Hand it to :class:`WorkflowTemplate.model_validate` — same strict
|
||||
schema the YAML loader uses. ValidationError → 422.
|
||||
3. Write atomically to ``<user_workflows>/<name>@<version>.yaml``.
|
||||
Refuse to overwrite an existing user template with the same key
|
||||
(use a new version).
|
||||
4. The hot-reload signature on the next GET picks the file up
|
||||
automatically — no restart needed.
|
||||
"""
|
||||
raw = body.model_dump(by_alias=True)
|
||||
try:
|
||||
tpl = WorkflowTemplate.model_validate(raw)
|
||||
except ValidationError as e:
|
||||
# `e.errors()` may put `ValueError` objects inside `ctx` (Pydantic
|
||||
# convention when a validator raises) — those don't JSON-serialise.
|
||||
# Flatten to a list[str] so the 422 body is always safe to dump.
|
||||
msgs = [
|
||||
f"{'.'.join(str(p) for p in err.get('loc', ()))}: {err.get('msg', '')}"
|
||||
for err in e.errors()
|
||||
]
|
||||
raise HTTPException(status_code=422, detail=msgs) from e
|
||||
|
||||
target_dir = user_workflows_dir(config)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = target_dir / f"{tpl.name}@{tpl.version}.yaml"
|
||||
if target.exists():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"workflow {tpl.name}@{tpl.version} already exists at "
|
||||
f"{target}. Bump the version or delete the file first."
|
||||
),
|
||||
)
|
||||
|
||||
serialised = yaml.safe_dump(
|
||||
tpl.model_dump(by_alias=True, mode="json"),
|
||||
allow_unicode=True,
|
||||
sort_keys=False,
|
||||
)
|
||||
target.write_text(serialised, encoding="utf-8")
|
||||
return CreateWorkflowResponse(path=str(target), name=tpl.name, version=tpl.version)
|
||||
|
||||
Reference in New Issue
Block a user