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:
chungyeong
2026-05-18 00:38:46 +09:00
parent 40ef833ad3
commit 6d371afadd
14 changed files with 1007 additions and 52 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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)