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:
@@ -2,6 +2,57 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **v0.4 — Workflow generator UI + hot-reload + UX polish**. 사용자가 직접
|
||||||
|
YAML 을 작성하지 않고도 브라우저에서 새 워크플로우 템플릿을 만들고 즉시
|
||||||
|
실행할 수 있도록 함. 메인 페이지 / new.html / runs.html / new-workflow.html
|
||||||
|
의 nav · copy · empty-state 도 동시에 정비.
|
||||||
|
- **A — `/new.html` UX 패치** (HTML/CSS only):
|
||||||
|
- 제목 "새 Run 시작" → "워크플로우 실행 (고급 기능)".
|
||||||
|
- 상단 `info-box`: "자유 대화는 여기가 아닙니다 → 메인 페이지" 안내 +
|
||||||
|
"+ 템플릿 만들기" 링크.
|
||||||
|
- 모든 필드에 한 줄 hint (예: `repo 절대경로 — 작업할 git 저장소 위치`).
|
||||||
|
- Persona 오버라이드를 `<details>` 접힘 상태로 → 첫 사용자가 압도되지
|
||||||
|
않도록.
|
||||||
|
- **B — nav 재정렬** (`/`, `/runs.html`, `/new.html`, `/run.html`,
|
||||||
|
`/conversation.html`):
|
||||||
|
- "대화" 가 `nav-primary` (큰 폰트 + 진한 색).
|
||||||
|
- "Runs" / "워크플로우 실행" / "+ 템플릿 만들기" 는 `nav-secondary`
|
||||||
|
(작은 폰트 + 65% opacity, hover 시 100%).
|
||||||
|
- **C — 메인 페이지 안내** + CSS:
|
||||||
|
- 메인 `/` 에 `info-box` 추가 ("👋 my-deepagent — OpenRouter 가성비 모델로
|
||||||
|
돌아가는 Claude Code 스타일 멀티턴 에이전트").
|
||||||
|
- `style.css` 에 `.info-box`, `.nav-primary`/`.nav-secondary`,
|
||||||
|
`.wf-row-card`, `.wf-chip` 등 신규 스타일 추가.
|
||||||
|
- **D — Workflow hot-reload**:
|
||||||
|
- `api/deps.py` 의 `get_workflows` 가 매 요청 시
|
||||||
|
`_workflow_dir_signature(config)` (seed + user 디렉터리의 mtime 튜플)
|
||||||
|
을 계산해 cached signature 와 다르면 `load_combined_workflows` 재호출.
|
||||||
|
파일 watcher / inotify 없이 stat 만으로 충분 (디렉터리가 작음).
|
||||||
|
- lifespan 의 `_load_seed_workflows` 도 `_load_workflows_combined` 로
|
||||||
|
교체해 user dir 첫 부팅 시도 자동 로드.
|
||||||
|
- **E — Workflow generator UI**:
|
||||||
|
- **API**: `POST /api/workflows` 신설. Body = `CreateWorkflowRequest`
|
||||||
|
pydantic (name / version / description / roles / phases / default_gates /
|
||||||
|
max_total_budget_usd). `WorkflowTemplate.model_validate` 로 strict
|
||||||
|
검증 → 실패 시 422 (loc:msg 포맷으로 평탄화). 성공 시
|
||||||
|
`<data_dir>/workflows/<name>@<version>.yaml` 에 YAML 저장 (`yaml.safe_dump
|
||||||
|
allow_unicode=True, sort_keys=False`). 중복 (name, version) 은 409.
|
||||||
|
- **HTML**: `static/new-workflow.html` (신규). 기본 정보 → Roles →
|
||||||
|
Phases → YAML 미리보기 → 저장 버튼.
|
||||||
|
- **JS**: `app.js` 에 `bootstrapWorkflowGenerator` 와 `WF_STATE` 추가.
|
||||||
|
Role 별 capability 를 chip 형태로 클릭 토글, Phase 의 role 셀렉트는
|
||||||
|
현재 Role 목록에서 동적으로 생성. 실시간 YAML preview. XSS 정책
|
||||||
|
유지 (모든 사용자 입력은 textContent).
|
||||||
|
- **신규 테스트** (`tests/integration/test_workflow_generator.py`, 7 케이스):
|
||||||
|
- `/new-workflow.html` 200 + 마크업
|
||||||
|
- POST happy path → yaml 파일 영속 + path / name / version 검증
|
||||||
|
- POST roles=[] → 422
|
||||||
|
- POST phase.role 미존재 → 422 + 메시지에 role id 포함
|
||||||
|
- POST duplicate (name, version) → 409
|
||||||
|
- GET hot-reload: POST 후 새 항목이 GET 응답에 등장
|
||||||
|
- GET hot-reload: 외부에서 YAML 파일 직접 떨궈도 mtime 으로 감지
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **v0.3 plan-conformance fixes** — 1차 구현 후 plan-v0.3 와 대조해 발견된 18건
|
- **v0.3 plan-conformance fixes** — 1차 구현 후 plan-v0.3 와 대조해 발견된 18건
|
||||||
누락/명세 위반을 보강. 자기 리뷰 3 라운드 (누락·미완 / 오류·엣지케이스 /
|
누락/명세 위반을 보강. 자기 리뷰 3 라운드 (누락·미완 / 오류·엣지케이스 /
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ from ..config import Config, load_config
|
|||||||
from ..persistence.checkpointer import get_checkpointer_ctx
|
from ..persistence.checkpointer import get_checkpointer_ctx
|
||||||
from ..persistence.db import Database
|
from ..persistence.db import Database
|
||||||
from ..persona import load_personas_from_dir
|
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 budget as budget_routes
|
||||||
from .routes import personas as personas_routes
|
from .routes import personas as personas_routes
|
||||||
from .routes import runs as runs_routes
|
from .routes import runs as runs_routes
|
||||||
@@ -33,24 +34,11 @@ _STATIC_ROOT = Path(__file__).resolve().parents[3] / "static"
|
|||||||
_LOG = logging.getLogger(__name__)
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _load_seed_workflows() -> list[tuple[Path, WorkflowTemplate]]:
|
def _load_workflows_combined(config: Config) -> list[tuple[Path, WorkflowTemplate]]:
|
||||||
"""Return (path, WorkflowTemplate) for every YAML in docs/schemas/workflows/.
|
"""Seed + user workflows. Malformed YAMLs are logged + skipped — the
|
||||||
|
API still comes up cleanly even if one file is broken. Per-request
|
||||||
Malformed YAMLs are logged and skipped — the API should still come up
|
hot-reload (`deps.get_workflows`) reuses the same loader."""
|
||||||
cleanly even if one seed is broken.
|
return load_combined_workflows(config, _DOCS_SCHEMAS / "workflows")
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -70,7 +58,9 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
app.state.config = config
|
app.state.config = config
|
||||||
app.state.db = db
|
app.state.db = db
|
||||||
app.state.personas = load_personas_from_dir(_DOCS_SCHEMAS / "personas")
|
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)
|
saver_ctx = get_checkpointer_ctx(config.database_url)
|
||||||
try:
|
try:
|
||||||
# AsyncPostgresSaver.from_conn_string only works for postgres; for sqlite
|
# 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
|
Pulls singletons stashed in `app.state` by the lifespan handler. Database is
|
||||||
created ONCE per uvicorn process; per-request creation would defeat
|
created ONCE per uvicorn process; per-request creation would defeat
|
||||||
connection pooling.
|
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
|
from __future__ import annotations
|
||||||
@@ -14,6 +20,7 @@ from fastapi import Request
|
|||||||
|
|
||||||
from ..config import Config
|
from ..config import Config
|
||||||
from ..persistence.db import Database
|
from ..persistence.db import Database
|
||||||
|
from ..user_dirs import load_combined_workflows, user_workflows_dir
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..persona import Persona
|
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]
|
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]]:
|
def get_workflows(request: Request) -> list[tuple[Path, WorkflowTemplate]]:
|
||||||
"""Return a list of (yaml_path, WorkflowTemplate) for all seed workflows."""
|
"""Return (path, template) list with mtime-based hot-reload.
|
||||||
return request.app.state.workflows # type: ignore[no-any-return]
|
|
||||||
|
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:
|
def seed_root() -> Path:
|
||||||
|
|||||||
@@ -128,6 +128,60 @@ class WorkflowSummary(_Strict):
|
|||||||
phases: list[WorkflowPhaseSummary]
|
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
|
# /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 __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
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 ...workflow import WorkflowTemplate
|
||||||
from ..deps import get_workflows, seed_root
|
from ..deps import get_config, get_workflows, seed_root
|
||||||
from ..models import WorkflowPhaseSummary, WorkflowRoleSummary, WorkflowSummary
|
from ..models import (
|
||||||
|
CreateWorkflowRequest,
|
||||||
|
CreateWorkflowResponse,
|
||||||
|
WorkflowPhaseSummary,
|
||||||
|
WorkflowRoleSummary,
|
||||||
|
WorkflowSummary,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
WorkflowsDep = Annotated[list[tuple[Path, WorkflowTemplate]], Depends(get_workflows)]
|
WorkflowsDep = Annotated[list[tuple[Path, WorkflowTemplate]], Depends(get_workflows)]
|
||||||
|
ConfigDep = Annotated[Config, Depends(get_config)]
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[WorkflowSummary])
|
@router.get("", response_model=list[WorkflowSummary])
|
||||||
@@ -50,3 +66,57 @@ async def list_workflows(workflows: WorkflowsDep) -> list[WorkflowSummary]:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return out
|
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)
|
||||||
|
|||||||
@@ -734,6 +734,284 @@ async function renderSessionsList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============== new-workflow.html (v0.4 generator) ===============
|
||||||
|
|
||||||
|
const _CAPABILITIES = [
|
||||||
|
"spec_write", "code_review", "evidence_check", "log_analysis", "decision",
|
||||||
|
"command_execute", "security_audit", "code_edit", "plan", "verify",
|
||||||
|
];
|
||||||
|
const _BACKENDS = ["openrouter", "anthropic", "ollama_local"];
|
||||||
|
const _RISKS = ["low", "medium", "high"];
|
||||||
|
|
||||||
|
const WF_STATE = {
|
||||||
|
roles: /** @type {Array<{id:string,capabilities:string[],backends:string[],fallbacks:string[]}>} */ ([]),
|
||||||
|
phases: /** @type {Array<{key:string,title:string,risk:string,role:string,instructions:string,artifactPath:string,artifactSchema:string,gates:string,timeout:string,budget:string}>} */ ([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
function _wfFreshRole() {
|
||||||
|
return { id: "", capabilities: [], backends: [], fallbacks: [] };
|
||||||
|
}
|
||||||
|
function _wfFreshPhase() {
|
||||||
|
return {
|
||||||
|
key: "", title: "", risk: "medium", role: "",
|
||||||
|
instructions: "", artifactPath: "", artifactSchema: "",
|
||||||
|
gates: "", timeout: "", budget: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wfChip(label, checked, onChange) {
|
||||||
|
const lbl = document.createElement("label");
|
||||||
|
lbl.className = "wf-chip";
|
||||||
|
const cb = document.createElement("input");
|
||||||
|
cb.type = "checkbox";
|
||||||
|
cb.checked = checked;
|
||||||
|
cb.addEventListener("change", () => onChange(cb.checked));
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.textContent = label;
|
||||||
|
lbl.appendChild(cb);
|
||||||
|
lbl.appendChild(span);
|
||||||
|
return lbl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wfTextInput(value, placeholder, onChange, type = "text") {
|
||||||
|
const i = document.createElement("input");
|
||||||
|
i.type = type;
|
||||||
|
i.value = value;
|
||||||
|
i.placeholder = placeholder;
|
||||||
|
i.addEventListener("input", () => onChange(i.value));
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wfTextArea(value, placeholder, onChange, rows = 3) {
|
||||||
|
const t = document.createElement("textarea");
|
||||||
|
t.value = value;
|
||||||
|
t.placeholder = placeholder;
|
||||||
|
t.rows = rows;
|
||||||
|
t.addEventListener("input", () => onChange(t.value));
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wfSelect(value, options, onChange) {
|
||||||
|
const s = document.createElement("select");
|
||||||
|
for (const o of options) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = o;
|
||||||
|
opt.textContent = o;
|
||||||
|
if (o === value) opt.selected = true;
|
||||||
|
s.appendChild(opt);
|
||||||
|
}
|
||||||
|
s.addEventListener("change", () => onChange(s.value));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRolesList() {
|
||||||
|
const container = $("#roles-list");
|
||||||
|
if (!container) return;
|
||||||
|
container.replaceChildren();
|
||||||
|
WF_STATE.roles.forEach((role, idx) => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "wf-row-card";
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "wf-row-header";
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = `Role #${idx + 1}`;
|
||||||
|
const del = document.createElement("button");
|
||||||
|
del.type = "button";
|
||||||
|
del.className = "button-link";
|
||||||
|
del.textContent = "삭제";
|
||||||
|
del.addEventListener("click", () => { WF_STATE.roles.splice(idx, 1); renderRolesList(); renderPreview(); });
|
||||||
|
header.append(title, del);
|
||||||
|
card.appendChild(header);
|
||||||
|
|
||||||
|
const idRow = document.createElement("div");
|
||||||
|
idRow.className = "form-row";
|
||||||
|
const idLbl = document.createElement("label");
|
||||||
|
idLbl.innerHTML = "id <span class='hint'>— phase 가 참조할 키. <code>writer</code> 같은 소문자/숫자/언더스코어</span>";
|
||||||
|
idRow.append(idLbl, _wfTextInput(role.id, "writer", (v) => { role.id = v; renderPreview(); }));
|
||||||
|
card.appendChild(idRow);
|
||||||
|
|
||||||
|
const capRow = document.createElement("div");
|
||||||
|
capRow.className = "form-row";
|
||||||
|
const capLbl = document.createElement("label");
|
||||||
|
capLbl.innerHTML = "required_capabilities <span class='hint'>— persona 가 가져야 할 능력 (최소 1)</span>";
|
||||||
|
const chips = document.createElement("div");
|
||||||
|
chips.className = "chips";
|
||||||
|
for (const c of _CAPABILITIES) {
|
||||||
|
chips.appendChild(_wfChip(c, role.capabilities.includes(c), (on) => {
|
||||||
|
if (on && !role.capabilities.includes(c)) role.capabilities.push(c);
|
||||||
|
else if (!on) role.capabilities = role.capabilities.filter((x) => x !== c);
|
||||||
|
renderPreview();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
capRow.append(capLbl, chips);
|
||||||
|
card.appendChild(capRow);
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
if (WF_STATE.roles.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "hint";
|
||||||
|
empty.textContent = "Role 이 1개 이상 필요합니다.";
|
||||||
|
container.appendChild(empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPhasesList() {
|
||||||
|
const container = $("#phases-list");
|
||||||
|
if (!container) return;
|
||||||
|
container.replaceChildren();
|
||||||
|
const roleIds = WF_STATE.roles.map((r) => r.id).filter(Boolean);
|
||||||
|
WF_STATE.phases.forEach((phase, idx) => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "wf-row-card";
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "wf-row-header";
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = `Phase #${idx + 1}`;
|
||||||
|
const del = document.createElement("button");
|
||||||
|
del.type = "button";
|
||||||
|
del.className = "button-link";
|
||||||
|
del.textContent = "삭제";
|
||||||
|
del.addEventListener("click", () => { WF_STATE.phases.splice(idx, 1); renderPhasesList(); renderPreview(); });
|
||||||
|
header.append(title, del);
|
||||||
|
card.appendChild(header);
|
||||||
|
|
||||||
|
const grid = document.createElement("div");
|
||||||
|
grid.className = "form-grid";
|
||||||
|
for (const [label, key, ph] of [
|
||||||
|
["key — 영문 소문자/숫자/언더스코어", "key", "spec"],
|
||||||
|
["title — 표시용 한 줄", "title", "명세 작성"],
|
||||||
|
]) {
|
||||||
|
const r = document.createElement("div");
|
||||||
|
r.className = "form-row";
|
||||||
|
const l = document.createElement("label");
|
||||||
|
l.textContent = label;
|
||||||
|
r.append(l, _wfTextInput(phase[key], ph, (v) => { phase[key] = v; renderPreview(); }));
|
||||||
|
grid.appendChild(r);
|
||||||
|
}
|
||||||
|
card.appendChild(grid);
|
||||||
|
|
||||||
|
const grid2 = document.createElement("div");
|
||||||
|
grid2.className = "form-grid";
|
||||||
|
const riskRow = document.createElement("div");
|
||||||
|
riskRow.className = "form-row";
|
||||||
|
const riskLbl = document.createElement("label");
|
||||||
|
riskLbl.innerHTML = "risk <span class='hint'>— 단계 위험 등급</span>";
|
||||||
|
riskRow.append(riskLbl, _wfSelect(phase.risk, _RISKS, (v) => { phase.risk = v; renderPreview(); }));
|
||||||
|
grid2.appendChild(riskRow);
|
||||||
|
const roleRow = document.createElement("div");
|
||||||
|
roleRow.className = "form-row";
|
||||||
|
const roleLbl = document.createElement("label");
|
||||||
|
roleLbl.innerHTML = "role <span class='hint'>— 위에서 정의한 role id 중 하나</span>";
|
||||||
|
const opts = roleIds.length > 0 ? roleIds : ["(role 을 먼저 정의)"];
|
||||||
|
roleRow.append(roleLbl, _wfSelect(phase.role, opts, (v) => { phase.role = v; renderPreview(); }));
|
||||||
|
grid2.appendChild(roleRow);
|
||||||
|
card.appendChild(grid2);
|
||||||
|
|
||||||
|
const insRow = document.createElement("div");
|
||||||
|
insRow.className = "form-row";
|
||||||
|
const insLbl = document.createElement("label");
|
||||||
|
insLbl.innerHTML = "instructions <span class='hint'>— 최소 10자. 이 phase 가 무엇을 해야 하는지</span>";
|
||||||
|
insRow.append(insLbl, _wfTextArea(phase.instructions,
|
||||||
|
"예: requirements.md 를 읽고 spec.md 를 작성하세요. 한국어 권장.",
|
||||||
|
(v) => { phase.instructions = v; renderPreview(); }, 4));
|
||||||
|
card.appendChild(insRow);
|
||||||
|
|
||||||
|
const grid3 = document.createElement("div");
|
||||||
|
grid3.className = "form-grid";
|
||||||
|
for (const [label, key, ph] of [
|
||||||
|
["expected_artifact.path (선택)", "artifactPath", "artifacts/spec.md"],
|
||||||
|
["expected_artifact.schema (선택)", "artifactSchema", "spec-v1"],
|
||||||
|
]) {
|
||||||
|
const r = document.createElement("div");
|
||||||
|
r.className = "form-row";
|
||||||
|
const l = document.createElement("label");
|
||||||
|
l.textContent = label;
|
||||||
|
r.append(l, _wfTextInput(phase[key], ph, (v) => { phase[key] = v; renderPreview(); }));
|
||||||
|
grid3.appendChild(r);
|
||||||
|
}
|
||||||
|
card.appendChild(grid3);
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
if (WF_STATE.phases.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "hint";
|
||||||
|
empty.textContent = "Phase 가 1개 이상 필요합니다.";
|
||||||
|
container.appendChild(empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wfBuildRequest() {
|
||||||
|
const name = $("#wf-name").value.trim();
|
||||||
|
const version = parseInt($("#wf-version").value, 10);
|
||||||
|
const description = $("#wf-description").value.trim();
|
||||||
|
const roles = WF_STATE.roles.filter((r) => r.id).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
required_capabilities: r.capabilities,
|
||||||
|
preferred_backends: r.backends,
|
||||||
|
fallback_personas: r.fallbacks,
|
||||||
|
}));
|
||||||
|
const phases = WF_STATE.phases.filter((p) => p.key).map((p) => {
|
||||||
|
const out = {
|
||||||
|
key: p.key,
|
||||||
|
title: p.title || p.key,
|
||||||
|
risk: p.risk,
|
||||||
|
role: p.role,
|
||||||
|
instructions: p.instructions || "(no instructions)",
|
||||||
|
gates: [],
|
||||||
|
};
|
||||||
|
if (p.artifactPath || p.artifactSchema) {
|
||||||
|
out.expected_artifact = {
|
||||||
|
path: p.artifactPath || "artifacts/output.md",
|
||||||
|
schema: p.artifactSchema || "text",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
const req = { name, version: isNaN(version) ? 1 : version, roles, phases, default_gates: [] };
|
||||||
|
if (description) req.description = description;
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview() {
|
||||||
|
const pre = $("#wf-preview");
|
||||||
|
if (!pre) return;
|
||||||
|
pre.textContent = JSON.stringify(_wfBuildRequest(), null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitWorkflow(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
setError("");
|
||||||
|
$("#success").style.display = "none";
|
||||||
|
const req = _wfBuildRequest();
|
||||||
|
try {
|
||||||
|
const ack = await jsonFetch("/workflows", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
const okBox = $("#success");
|
||||||
|
okBox.textContent = `✅ 저장 완료 → ${ack.path}. 워크플로우 실행 페이지에서 바로 보입니다.`;
|
||||||
|
okBox.style.display = "block";
|
||||||
|
} catch (e) {
|
||||||
|
setError(`저장 실패: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bootstrapWorkflowGenerator() {
|
||||||
|
WF_STATE.roles = [_wfFreshRole()];
|
||||||
|
WF_STATE.phases = [_wfFreshPhase()];
|
||||||
|
renderRolesList();
|
||||||
|
renderPhasesList();
|
||||||
|
renderPreview();
|
||||||
|
$("#add-role").addEventListener("click", () => { WF_STATE.roles.push(_wfFreshRole()); renderRolesList(); renderPreview(); });
|
||||||
|
$("#add-phase").addEventListener("click", () => { WF_STATE.phases.push(_wfFreshPhase()); renderPhasesList(); renderPreview(); });
|
||||||
|
$("#wf-name").addEventListener("input", renderPreview);
|
||||||
|
$("#wf-version").addEventListener("input", renderPreview);
|
||||||
|
$("#wf-description").addEventListener("input", renderPreview);
|
||||||
|
$("#wf-form").addEventListener("submit", submitWorkflow);
|
||||||
|
}
|
||||||
|
|
||||||
// =============== bootstrap ===============
|
// =============== bootstrap ===============
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
@@ -751,5 +1029,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
$("#resume-btn").addEventListener("click", resumeRun);
|
$("#resume-btn").addEventListener("click", resumeRun);
|
||||||
} else if (page === "conversation") {
|
} else if (page === "conversation") {
|
||||||
bootstrapConversationPage();
|
bootstrapConversationPage();
|
||||||
|
} else if (page === "new-workflow") {
|
||||||
|
bootstrapWorkflowGenerator();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1><a href="/">my-deepagent</a></h1>
|
<h1><a href="/">my-deepagent</a></h1>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">세션 목록</a>
|
<a href="/" class="nav-primary">세션 목록</a>
|
||||||
<a href="/conversation.html" class="active">대화</a>
|
<a href="/conversation.html" class="active nav-primary">대화</a>
|
||||||
<a href="/runs.html">Runs (archive)</a>
|
<a href="/runs.html" class="nav-secondary">Runs</a>
|
||||||
|
<a href="/new.html" class="nav-secondary">워크플로우 실행</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="conversation-main">
|
<main class="conversation-main">
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1><a href="/">my-deepagent</a></h1>
|
<h1><a href="/">my-deepagent</a></h1>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/" class="active">대화</a>
|
<a href="/" class="active nav-primary">대화</a>
|
||||||
<a href="/runs.html">Runs (archive)</a>
|
<a href="/runs.html" class="nav-secondary">Runs</a>
|
||||||
<a href="/new.html">새 Workflow Run</a>
|
<a href="/new.html" class="nav-secondary">워크플로우 실행</a>
|
||||||
|
<a href="/new-workflow.html" class="nav-secondary">+ 템플릿 만들기</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
@@ -23,6 +24,12 @@
|
|||||||
<span class="page-subtitle">최근 50개 · 빈 화면이면 아래 "새 대화"를 누르세요</span>
|
<span class="page-subtitle">최근 50개 · 빈 화면이면 아래 "새 대화"를 누르세요</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>👋 my-deepagent</strong> — OpenRouter 가성비 모델로 돌아가는 Claude Code 스타일 멀티턴 에이전트.
|
||||||
|
대부분의 경우 아래 <strong>"새 대화 시작"</strong>만 누르면 됩니다.
|
||||||
|
<a href="/new.html">여러 단계 자동화</a>가 필요하면 워크플로우, <a href="/new-workflow.html">템플릿 직접 만들기</a>도 가능.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="action-bar" style="margin-bottom: 12px;">
|
<div class="action-bar" style="margin-bottom: 12px;">
|
||||||
<a class="button primary" href="/conversation.html">▶︎ 새 대화 시작</a>
|
<a class="button primary" href="/conversation.html">▶︎ 새 대화 시작</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
99
my-deepagent/static/new-workflow.html
Normal file
99
my-deepagent/static/new-workflow.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>my-deepagent · 워크플로우 템플릿 만들기</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body data-page="new-workflow">
|
||||||
|
<header>
|
||||||
|
<h1><a href="/">my-deepagent</a></h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/" class="nav-primary">대화</a>
|
||||||
|
<a href="/runs.html" class="nav-secondary">Runs</a>
|
||||||
|
<a href="/new.html" class="nav-secondary">워크플로우 실행</a>
|
||||||
|
<a href="/new-workflow.html" class="active nav-secondary">+ 템플릿 만들기</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div id="error" class="error-banner" style="display:none"></div>
|
||||||
|
<div id="success" class="info-box" style="display:none"></div>
|
||||||
|
|
||||||
|
<div class="page-title">
|
||||||
|
<h2>워크플로우 템플릿 만들기</h2>
|
||||||
|
<span class="page-subtitle">phase 시퀀스 + role 정의 → YAML 저장</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>📘 워크플로우 = phase 시퀀스</strong><br />
|
||||||
|
예: <code>"명세 작성" → "리뷰" → "검증"</code> 처럼 단계별로 어떤 role(역할)이 어떤
|
||||||
|
산출물을 만들지 정의하는 파일입니다. 저장 후엔 <a href="/new.html">워크플로우 실행</a>
|
||||||
|
페이지의 드롭다운에 자동으로 등장합니다 (서버 재시작 불필요).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="wf-form" autocomplete="off">
|
||||||
|
|
||||||
|
<!-- 기본 메타 -->
|
||||||
|
<div class="card" style="padding: 20px;">
|
||||||
|
<h3 class="section-title" style="margin-top:0">기본 정보</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="wf-name">
|
||||||
|
name
|
||||||
|
<span class="hint">— 영문 소문자/숫자/하이픈만. 예: <code>spec-and-review</code></span>
|
||||||
|
</label>
|
||||||
|
<input id="wf-name" type="text" required placeholder="my-workflow" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="wf-version">
|
||||||
|
version
|
||||||
|
<span class="hint">— 정수, 1부터</span>
|
||||||
|
</label>
|
||||||
|
<input id="wf-version" type="number" required value="1" min="1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="wf-description">
|
||||||
|
description
|
||||||
|
<span class="hint">— 한 줄 설명 (선택)</span>
|
||||||
|
</label>
|
||||||
|
<input id="wf-description" type="text" placeholder="이 워크플로우가 무엇을 하는지" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Roles -->
|
||||||
|
<div class="card" style="padding: 20px; margin-top: 16px;">
|
||||||
|
<h3 class="section-title" style="margin-top:0">
|
||||||
|
Roles <span class="hint" style="font-weight:400">— phase 가 참조할 역할 정의</span>
|
||||||
|
</h3>
|
||||||
|
<div id="roles-list"></div>
|
||||||
|
<button type="button" id="add-role" class="button">+ Role 추가</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phases -->
|
||||||
|
<div class="card" style="padding: 20px; margin-top: 16px;">
|
||||||
|
<h3 class="section-title" style="margin-top:0">
|
||||||
|
Phases <span class="hint" style="font-weight:400">— 실제 실행되는 단계 순서</span>
|
||||||
|
</h3>
|
||||||
|
<div id="phases-list"></div>
|
||||||
|
<button type="button" id="add-phase" class="button">+ Phase 추가</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<details class="card" style="padding: 16px; margin-top: 16px;">
|
||||||
|
<summary style="cursor:pointer; font-weight:600;">
|
||||||
|
YAML 미리보기 <span class="hint" style="font-weight:400">— 저장될 파일 내용</span>
|
||||||
|
</summary>
|
||||||
|
<pre id="wf-preview" class="mono" style="margin-top:12px; white-space:pre-wrap; font-size:12.5px;"></pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="action-bar">
|
||||||
|
<button type="submit" class="primary">💾 저장 + 등록</button>
|
||||||
|
<a class="button" href="/">취소</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3,56 +3,85 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>my-deepagent · 새 Run</title>
|
<title>my-deepagent · 워크플로우 실행</title>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body data-page="new">
|
<body data-page="new">
|
||||||
<header>
|
<header>
|
||||||
<h1><a href="/">my-deepagent</a></h1>
|
<h1><a href="/">my-deepagent</a></h1>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">대화</a>
|
<a href="/" class="nav-primary">대화</a>
|
||||||
<a href="/runs.html">Runs (archive)</a>
|
<a href="/runs.html" class="nav-secondary">Runs</a>
|
||||||
<a href="/new.html" class="active">새 Workflow Run</a>
|
<a href="/new.html" class="active nav-secondary">워크플로우 실행</a>
|
||||||
|
<a href="/new-workflow.html" class="nav-secondary">+ 템플릿 만들기</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<div id="error" class="error-banner" style="display:none"></div>
|
<div id="error" class="error-banner" style="display:none"></div>
|
||||||
|
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<h2>새 Run 시작</h2>
|
<h2>워크플로우 실행 <span class="hint" style="font-size: 12px; vertical-align: middle;">(고급 기능)</span></h2>
|
||||||
<span class="page-subtitle">워크플로우 + repo + 요구사항</span>
|
<span class="page-subtitle">사전 정의된 phase 시퀀스로 자동화된 작업 실행</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>💡 자유 대화는 여기가 아닙니다.</strong>
|
||||||
|
그냥 챗봇처럼 쓰고 싶다면 <a href="/">메인 페이지의 "새 대화 시작"</a>을 눌러주세요.
|
||||||
|
이 페이지는 <strong>여러 단계 (예: 명세 → 리뷰 → 검증)</strong> 가 정해진 순서로 자동 실행되는 워크플로우를 시작할 때 씁니다.
|
||||||
|
<br /><br />
|
||||||
|
<strong>새 템플릿을 직접 만들고 싶다면</strong> 우상단 <a href="/new-workflow.html">+ 템플릿 만들기</a>로 가세요.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="start-form" autocomplete="off">
|
<form id="start-form" autocomplete="off">
|
||||||
<div class="card" style="padding: 20px;">
|
<div class="card" style="padding: 20px;">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="template">워크플로우 템플릿</label>
|
<label for="template">
|
||||||
|
워크플로우 템플릿
|
||||||
|
<span class="hint">— 무슨 단계를 어떤 순서로 돌릴지 정의한 YAML. 모르면 첫 번째 선택.</span>
|
||||||
|
</label>
|
||||||
<select id="template" required></select>
|
<select id="template" required></select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="repo-path">repo 절대경로</label>
|
<label for="repo-path">
|
||||||
|
repo 절대경로
|
||||||
|
<span class="hint">— 작업할 git 저장소 위치 (예: /Users/me/projects/my-thing)</span>
|
||||||
|
</label>
|
||||||
<input id="repo-path" type="text" placeholder="/Users/me/projects/my-thing" required />
|
<input id="repo-path" type="text" placeholder="/Users/me/projects/my-thing" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="base-branch">base branch</label>
|
<label for="base-branch">
|
||||||
|
base branch
|
||||||
|
<span class="hint">— 작업의 시작점 (보통 main)</span>
|
||||||
|
</label>
|
||||||
<input id="base-branch" type="text" value="main" />
|
<input id="base-branch" type="text" value="main" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="requirements">requirements <span class="hint">— 자유 텍스트, 마크다운 OK</span></label>
|
<label for="requirements">
|
||||||
<textarea id="requirements" rows="6" placeholder="이 workflow가 다룰 요구사항을 적어주세요."></textarea>
|
requirements
|
||||||
|
<span class="hint">— 이 워크플로우가 다룰 요구사항. 자유 텍스트, 마크다운 OK</span>
|
||||||
|
</label>
|
||||||
|
<textarea id="requirements" rows="6" placeholder="예: wordcount CLI를 만들어줘. python으로, pytest 테스트 포함."></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="section-title">Persona 오버라이드 <span class="hint" style="text-transform: none; letter-spacing: 0; font-weight: 400;">(선택, 비우면 자동 선택)</span></h2>
|
<details class="card" style="margin-top: 16px; padding: 16px;">
|
||||||
<div id="override-fields" class="card"></div>
|
<summary style="cursor: pointer; font-weight: 600;">
|
||||||
|
Persona 오버라이드 <span class="hint" style="font-weight: 400;">— 비우면 자동 선택 (고급)</span>
|
||||||
|
</summary>
|
||||||
|
<p class="hint" style="margin-top: 12px; font-weight: 400;">
|
||||||
|
각 단계(role)에 어떤 persona(AI 모델 + 시스템 프롬프트)를 쓸지 직접 고르고 싶을 때만 채우세요.
|
||||||
|
비워두면 capability 매칭으로 자동 선택됩니다.
|
||||||
|
</p>
|
||||||
|
<div id="override-fields"></div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
<button type="submit" class="primary">▶︎ 시작</button>
|
<button type="submit" class="primary">▶︎ 워크플로우 실행</button>
|
||||||
<a class="button" href="/">취소</a>
|
<a class="button" href="/">취소</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1><a href="/">my-deepagent</a></h1>
|
<h1><a href="/">my-deepagent</a></h1>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">대화</a>
|
<a href="/" class="nav-primary">대화</a>
|
||||||
<a href="/runs.html">Runs (archive)</a>
|
<a href="/runs.html" class="nav-secondary">Runs</a>
|
||||||
<a href="/new.html">새 Workflow Run</a>
|
<a href="/new.html" class="nav-secondary">워크플로우 실행</a>
|
||||||
|
<a href="/new-workflow.html" class="nav-secondary">+ 템플릿 만들기</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1><a href="/">my-deepagent</a></h1>
|
<h1><a href="/">my-deepagent</a></h1>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">대화</a>
|
<a href="/" class="nav-primary">대화</a>
|
||||||
<a href="/runs.html" class="active">Runs (archive)</a>
|
<a href="/runs.html" class="active nav-secondary">Runs</a>
|
||||||
<a href="/new.html">새 Workflow Run</a>
|
<a href="/new.html" class="nav-secondary">워크플로우 실행</a>
|
||||||
|
<a href="/new-workflow.html" class="nav-secondary">+ 템플릿 만들기</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@@ -962,3 +962,134 @@ select {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
v0.4 — nav tiers + info-box + empty-state polish
|
||||||
|
================================================================= */
|
||||||
|
|
||||||
|
nav .nav-primary {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav .nav-secondary {
|
||||||
|
font-size: 12.5px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav .nav-secondary:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a.active.nav-primary,
|
||||||
|
nav a.active.nav-secondary {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
border-left: 4px solid rgb(245, 158, 11);
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: rgb(95, 50, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box strong {
|
||||||
|
color: rgb(75, 35, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box a {
|
||||||
|
color: rgb(180, 70, 30);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* details/summary polish */
|
||||||
|
details summary {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] summary {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* index empty state — prominent CTA */
|
||||||
|
.empty-cta {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-cta-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-cta-subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-cta .button {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
v0.4 — workflow generator UI
|
||||||
|
================================================================= */
|
||||||
|
|
||||||
|
.wf-row-card {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-row-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgb(180, 70, 30);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
text-decoration: underline;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: rgba(180, 70, 30, 0.06);
|
||||||
|
border: 1px solid rgba(180, 70, 30, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 2px 4px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-chip input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-chip:has(input:checked) {
|
||||||
|
background: rgba(180, 70, 30, 0.18);
|
||||||
|
border-color: rgba(180, 70, 30, 0.5);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
202
my-deepagent/tests/integration/test_workflow_generator.py
Normal file
202
my-deepagent/tests/integration/test_workflow_generator.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""v0.4 — Workflow generator UI + hot-reload tests.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
1. POST /api/workflows persists a YAML under <data_dir>/workflows/
|
||||||
|
2. POST rejects malformed body with 422
|
||||||
|
3. POST rejects duplicate (name, version) with 409
|
||||||
|
4. GET /api/workflows hot-reloads when a new file appears
|
||||||
|
5. GET /api/workflows hot-reloads when an existing file is edited
|
||||||
|
6. /new-workflow.html serves with the page marker
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from my_deepagent.api.app import create_app
|
||||||
|
from my_deepagent.config import load_config
|
||||||
|
from my_deepagent.persistence.db import Database
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def app_client(tmp_path: Path) -> AsyncIterator[tuple[AsyncClient, FastAPI, Path]]:
|
||||||
|
db_url = f"sqlite+aiosqlite:///{tmp_path / 'gen.sqlite3'}"
|
||||||
|
cfg = load_config(
|
||||||
|
workspace_root=tmp_path,
|
||||||
|
data_dir=tmp_path / "data",
|
||||||
|
database_url=db_url,
|
||||||
|
)
|
||||||
|
db = Database(db_url)
|
||||||
|
await db.init_schema()
|
||||||
|
await db.dispose()
|
||||||
|
app = create_app(cfg)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test", timeout=10.0) as client:
|
||||||
|
yield (client, app, cfg.data_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_body(name: str = "my-flow", version: int = 1) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"version": version,
|
||||||
|
"description": "test workflow generator",
|
||||||
|
"roles": [{"id": "writer", "required_capabilities": ["code_edit"]}],
|
||||||
|
"phases": [
|
||||||
|
{
|
||||||
|
"key": "p1",
|
||||||
|
"title": "first phase",
|
||||||
|
"risk": "medium",
|
||||||
|
"role": "writer",
|
||||||
|
"instructions": "do something useful in this phase",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Static page
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_workflow_page_served(
|
||||||
|
app_client: tuple[AsyncClient, FastAPI, Path],
|
||||||
|
) -> None:
|
||||||
|
client, _app, _dir = app_client
|
||||||
|
r = await client.get("/new-workflow.html")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert 'data-page="new-workflow"' in r.text
|
||||||
|
assert "워크플로우 템플릿 만들기" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/workflows happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_workflow_creates_yaml_under_data_dir(
|
||||||
|
app_client: tuple[AsyncClient, FastAPI, Path],
|
||||||
|
) -> None:
|
||||||
|
client, _app, data_dir = app_client
|
||||||
|
r = await client.post("/api/workflows", json=_valid_body())
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
body = r.json()
|
||||||
|
target = Path(body["path"])
|
||||||
|
assert target.is_file()
|
||||||
|
assert target.parent == data_dir / "workflows"
|
||||||
|
assert target.name == "my-flow@1.yaml"
|
||||||
|
parsed = yaml.safe_load(target.read_text(encoding="utf-8"))
|
||||||
|
assert parsed["name"] == "my-flow"
|
||||||
|
assert parsed["version"] == 1
|
||||||
|
assert parsed["phases"][0]["key"] == "p1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validation rejection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_workflow_rejects_missing_roles(
|
||||||
|
app_client: tuple[AsyncClient, FastAPI, Path],
|
||||||
|
) -> None:
|
||||||
|
client, _app, _dir = app_client
|
||||||
|
bad = _valid_body()
|
||||||
|
bad["roles"] = [] # min_length=1 violation
|
||||||
|
r = await client.post("/api/workflows", json=bad)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_workflow_rejects_phase_referencing_unknown_role(
|
||||||
|
app_client: tuple[AsyncClient, FastAPI, Path],
|
||||||
|
) -> None:
|
||||||
|
client, _app, _dir = app_client
|
||||||
|
bad = _valid_body()
|
||||||
|
bad["phases"][0]["role"] = "ghost-role" # type: ignore[index]
|
||||||
|
r = await client.post("/api/workflows", json=bad)
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert "ghost-role" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Duplicate refusal
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_workflow_rejects_duplicate_name_version(
|
||||||
|
app_client: tuple[AsyncClient, FastAPI, Path],
|
||||||
|
) -> None:
|
||||||
|
client, _app, _dir = app_client
|
||||||
|
body = _valid_body("dup-flow", 1)
|
||||||
|
r1 = await client.post("/api/workflows", json=body)
|
||||||
|
assert r1.status_code == 201
|
||||||
|
r2 = await client.post("/api/workflows", json=body)
|
||||||
|
assert r2.status_code == 409
|
||||||
|
assert "already exists" in r2.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hot-reload — new file appears in GET
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_workflows_hot_reloads_after_post(
|
||||||
|
app_client: tuple[AsyncClient, FastAPI, Path],
|
||||||
|
) -> None:
|
||||||
|
client, _app, _dir = app_client
|
||||||
|
before = await client.get("/api/workflows")
|
||||||
|
before_names = {w["name"] for w in before.json()}
|
||||||
|
assert "fresh-flow" not in before_names
|
||||||
|
|
||||||
|
r = await client.post("/api/workflows", json=_valid_body("fresh-flow", 1))
|
||||||
|
assert r.status_code == 201
|
||||||
|
|
||||||
|
after = await client.get("/api/workflows")
|
||||||
|
after_names = {w["name"] for w in after.json()}
|
||||||
|
assert "fresh-flow" in after_names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_workflows_hot_reloads_after_external_file_drop(
|
||||||
|
app_client: tuple[AsyncClient, FastAPI, Path],
|
||||||
|
) -> None:
|
||||||
|
"""Even when the file is dropped directly into the dir (not via POST),
|
||||||
|
the next GET picks it up via the mtime fingerprint."""
|
||||||
|
from textwrap import dedent
|
||||||
|
|
||||||
|
client, _app, data_dir = app_client
|
||||||
|
wf_dir = data_dir / "workflows"
|
||||||
|
wf_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(wf_dir / "external@1.yaml").write_text(
|
||||||
|
dedent(
|
||||||
|
"""\
|
||||||
|
name: external
|
||||||
|
version: 1
|
||||||
|
description: dropped by hand
|
||||||
|
roles:
|
||||||
|
- id: writer
|
||||||
|
required_capabilities: [code_edit]
|
||||||
|
phases:
|
||||||
|
- key: p1
|
||||||
|
title: only phase
|
||||||
|
risk: low
|
||||||
|
role: writer
|
||||||
|
instructions: just write something to disk
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
r = await client.get("/api/workflows")
|
||||||
|
names = {w["name"] for w in r.json()}
|
||||||
|
assert "external" in names
|
||||||
Reference in New Issue
Block a user