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

@@ -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 라운드 (누락·미완 / 오류·엣지케이스 /

View File

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

View File

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

View File

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

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

View File

@@ -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();
} }
}); });

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View 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