From 6d371afadd4bc93a26057cc1c0da59acdc93e269 Mon Sep 17 00:00:00 2001 From: chungyeong Date: Mon, 18 May 2026 00:38:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(my-deepagent):=20v0.4=20=E2=80=94=20workfl?= =?UTF-8?q?ow=20generator=20UI=20+=20hot-reload=20+=20UX=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브라우저에서 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 오버라이드
접힘 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 → /workflows/@.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) --- my-deepagent/CHANGELOG.md | 51 ++++ my-deepagent/src/my_deepagent/api/app.py | 30 +- my-deepagent/src/my_deepagent/api/deps.py | 43 ++- my-deepagent/src/my_deepagent/api/models.py | 54 ++++ .../src/my_deepagent/api/routes/workflows.py | 78 ++++- my-deepagent/static/app.js | 280 ++++++++++++++++++ my-deepagent/static/conversation.html | 7 +- my-deepagent/static/index.html | 13 +- my-deepagent/static/new-workflow.html | 99 +++++++ my-deepagent/static/new.html | 57 +++- my-deepagent/static/run.html | 7 +- my-deepagent/static/runs.html | 7 +- my-deepagent/static/style.css | 131 ++++++++ .../integration/test_workflow_generator.py | 202 +++++++++++++ 14 files changed, 1007 insertions(+), 52 deletions(-) create mode 100644 my-deepagent/static/new-workflow.html create mode 100644 my-deepagent/tests/integration/test_workflow_generator.py diff --git a/my-deepagent/CHANGELOG.md b/my-deepagent/CHANGELOG.md index 4df8032..095e3ca 100644 --- a/my-deepagent/CHANGELOG.md +++ b/my-deepagent/CHANGELOG.md @@ -2,6 +2,57 @@ ## [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 오버라이드를 `
` 접힘 상태로 → 첫 사용자가 압도되지 + 않도록. + - **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 포맷으로 평탄화). 성공 시 + `/workflows/@.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 - **v0.3 plan-conformance fixes** — 1차 구현 후 plan-v0.3 와 대조해 발견된 18건 누락/명세 위반을 보강. 자기 리뷰 3 라운드 (누락·미완 / 오류·엣지케이스 / diff --git a/my-deepagent/src/my_deepagent/api/app.py b/my-deepagent/src/my_deepagent/api/app.py index 5aa660a..9ae2365 100644 --- a/my-deepagent/src/my_deepagent/api/app.py +++ b/my-deepagent/src/my_deepagent/api/app.py @@ -20,7 +20,8 @@ from ..config import Config, load_config from ..persistence.checkpointer import get_checkpointer_ctx from ..persistence.db import Database from ..persona import load_personas_from_dir -from ..workflow import WorkflowTemplate, load_workflow_yaml +from ..user_dirs import load_combined_workflows +from ..workflow import WorkflowTemplate from .routes import budget as budget_routes from .routes import personas as personas_routes from .routes import runs as runs_routes @@ -33,24 +34,11 @@ _STATIC_ROOT = Path(__file__).resolve().parents[3] / "static" _LOG = logging.getLogger(__name__) -def _load_seed_workflows() -> list[tuple[Path, WorkflowTemplate]]: - """Return (path, WorkflowTemplate) for every YAML in docs/schemas/workflows/. - - Malformed YAMLs are logged and skipped — the API should still come up - cleanly even if one seed is broken. - """ - wf_dir = _DOCS_SCHEMAS / "workflows" - if not wf_dir.is_dir(): - return [] - out: list[tuple[Path, WorkflowTemplate]] = [] - for p in sorted(wf_dir.glob("*.yaml")): - try: - tpl = load_workflow_yaml(p) - except Exception as e: - _LOG.warning("skipping malformed workflow yaml %s: %s", p, e) - continue - out.append((p, tpl)) - return out +def _load_workflows_combined(config: Config) -> list[tuple[Path, WorkflowTemplate]]: + """Seed + user workflows. Malformed YAMLs are logged + skipped — the + API still comes up cleanly even if one file is broken. Per-request + hot-reload (`deps.get_workflows`) reuses the same loader.""" + return load_combined_workflows(config, _DOCS_SCHEMAS / "workflows") @asynccontextmanager @@ -70,7 +58,9 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]: app.state.config = config app.state.db = db app.state.personas = load_personas_from_dir(_DOCS_SCHEMAS / "personas") - app.state.workflows = _load_seed_workflows() + app.state.workflows = _load_workflows_combined(config) + # Hot-reload signature — `deps.get_workflows` re-checks per request. + app.state.workflows_sig = None saver_ctx = get_checkpointer_ctx(config.database_url) try: # AsyncPostgresSaver.from_conn_string only works for postgres; for sqlite diff --git a/my-deepagent/src/my_deepagent/api/deps.py b/my-deepagent/src/my_deepagent/api/deps.py index af10203..bd38ef7 100644 --- a/my-deepagent/src/my_deepagent/api/deps.py +++ b/my-deepagent/src/my_deepagent/api/deps.py @@ -3,6 +3,12 @@ Pulls singletons stashed in `app.state` by the lifespan handler. Database is created ONCE per uvicorn process; per-request creation would defeat connection pooling. + +Workflows are different — they live in YAML files that the user can edit / +create at runtime via the workflow generator UI. `get_workflows` does a +cheap mtime check on every call and reloads when any file in the seed or +user workflow directory has changed. No file watcher / inotify needed — +the directories are tiny (≤ dozens of files) and stat is cheap. """ from __future__ import annotations @@ -14,6 +20,7 @@ from fastapi import Request from ..config import Config from ..persistence.db import Database +from ..user_dirs import load_combined_workflows, user_workflows_dir if TYPE_CHECKING: from ..persona import Persona @@ -36,9 +43,41 @@ def get_personas(request: Request) -> list[Persona]: return request.app.state.personas # type: ignore[no-any-return] +def _workflow_dir_signature(config: Config) -> tuple[tuple[str, float], ...]: + """Cheap mtime-tuple fingerprint of all YAMLs in seed + user dirs. + + Two stat calls per file; the fingerprint changes when any file is + created / modified / deleted. Used as the cache key for + :func:`get_workflows` so the API picks up new templates without a + process restart. + """ + sig: list[tuple[str, float]] = [] + for d in (_DOCS_SCHEMAS / "workflows", user_workflows_dir(config)): + if not d.is_dir(): + continue + for p in sorted(d.glob("*.yaml")): + try: + sig.append((str(p), p.stat().st_mtime)) + except OSError: + continue + return tuple(sig) + + def get_workflows(request: Request) -> list[tuple[Path, WorkflowTemplate]]: - """Return a list of (yaml_path, WorkflowTemplate) for all seed workflows.""" - return request.app.state.workflows # type: ignore[no-any-return] + """Return (path, template) list with mtime-based hot-reload. + + On every request, computes the mtime fingerprint of the workflow dirs. + If it differs from the cached signature, calls + :func:`load_combined_workflows` again to pick up new / edited files. + """ + app = request.app + config: Config = app.state.config + current_sig = _workflow_dir_signature(config) + cached_sig: tuple[tuple[str, float], ...] | None = getattr(app.state, "workflows_sig", None) + if cached_sig != current_sig: + app.state.workflows = load_combined_workflows(config, _DOCS_SCHEMAS / "workflows") + app.state.workflows_sig = current_sig + return app.state.workflows # type: ignore[no-any-return] def seed_root() -> Path: diff --git a/my-deepagent/src/my_deepagent/api/models.py b/my-deepagent/src/my_deepagent/api/models.py index 588be43..816f97e 100644 --- a/my-deepagent/src/my_deepagent/api/models.py +++ b/my-deepagent/src/my_deepagent/api/models.py @@ -128,6 +128,60 @@ class WorkflowSummary(_Strict): phases: list[WorkflowPhaseSummary] +# v0.4 — workflow generator UI (POST /api/workflows) + + +class WorkflowRoleSpec(_Strict): + """Input shape for one role inside a CreateWorkflowRequest.""" + + id: str = Field(min_length=1, pattern=r"^[a-z][a-z0-9_]*$") + required_capabilities: list[str] = Field(min_length=1) + preferred_backends: list[str] = Field(default_factory=list) + fallback_personas: list[str] = Field(default_factory=list) + + +class WorkflowArtifactSpec(_Strict): + """Input shape for one phase's expected_artifact (optional).""" + + path: str = Field(min_length=1) + # YAML key is `schema`; pydantic attribute aliased to avoid BaseModel.schema clash + schema_id: str = Field(min_length=1, alias="schema") + + +class WorkflowPhaseSpec(_Strict): + """Input shape for one phase inside a CreateWorkflowRequest.""" + + key: str = Field(min_length=1, pattern=r"^[a-z][a-z0-9_]*$") + title: str = Field(min_length=1) + risk: str = Field(min_length=1) # low|medium|high — validated by WorkflowTemplate + role: str = Field(min_length=1) + instructions: str = Field(min_length=10) + expected_artifact: WorkflowArtifactSpec | None = None + gates: list[str] = Field(default_factory=list) + timeout_seconds: int | None = Field(default=None, ge=1) + max_budget_usd: float | None = Field(default=None, ge=0) + + +class CreateWorkflowRequest(_Strict): + """Body for POST /api/workflows — saves a new template YAML on disk.""" + + name: str = Field(min_length=1) + version: int = Field(ge=1) + description: str | None = None + roles: list[WorkflowRoleSpec] = Field(min_length=1) + phases: list[WorkflowPhaseSpec] = Field(min_length=1) + default_gates: list[str] = Field(default_factory=list) + max_total_budget_usd: float | None = Field(default=None, ge=0) + + +class CreateWorkflowResponse(_Strict): + """Returned by POST /api/workflows.""" + + path: str # absolute path of the saved YAML + name: str + version: int + + # --------------------------------------------------------------------------- # /api/budget # --------------------------------------------------------------------------- diff --git a/my-deepagent/src/my_deepagent/api/routes/workflows.py b/my-deepagent/src/my_deepagent/api/routes/workflows.py index 19570d0..306d81b 100644 --- a/my-deepagent/src/my_deepagent/api/routes/workflows.py +++ b/my-deepagent/src/my_deepagent/api/routes/workflows.py @@ -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 +``/workflows/@.yaml`` so the workflow +generator UI can create templates without leaving the browser. +""" from __future__ import annotations from pathlib import Path from typing import Annotated -from fastapi import APIRouter, Depends +import yaml +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import ValidationError +from ...config import Config +from ...user_dirs import user_workflows_dir from ...workflow import WorkflowTemplate -from ..deps import get_workflows, seed_root -from ..models import WorkflowPhaseSummary, WorkflowRoleSummary, WorkflowSummary +from ..deps import get_config, get_workflows, seed_root +from ..models import ( + CreateWorkflowRequest, + CreateWorkflowResponse, + WorkflowPhaseSummary, + WorkflowRoleSummary, + WorkflowSummary, +) router = APIRouter() WorkflowsDep = Annotated[list[tuple[Path, WorkflowTemplate]], Depends(get_workflows)] +ConfigDep = Annotated[Config, Depends(get_config)] @router.get("", response_model=list[WorkflowSummary]) @@ -50,3 +66,57 @@ async def list_workflows(workflows: WorkflowsDep) -> list[WorkflowSummary]: ) ) return out + + +@router.post( + "", + response_model=CreateWorkflowResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_workflow(body: CreateWorkflowRequest, config: ConfigDep) -> CreateWorkflowResponse: + """Persist a new WorkflowTemplate YAML under the user workflows dir. + + Pipeline: + 1. Convert request → dict (frontmatter aliases preserved: ``schema`` + not ``schema_id`` so the file is round-tripped by + :func:`load_workflow_yaml`). + 2. Hand it to :class:`WorkflowTemplate.model_validate` — same strict + schema the YAML loader uses. ValidationError → 422. + 3. Write atomically to ``/@.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) diff --git a/my-deepagent/static/app.js b/my-deepagent/static/app.js index 4b06937..b4d0af2 100644 --- a/my-deepagent/static/app.js +++ b/my-deepagent/static/app.js @@ -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 — phase 가 참조할 키. writer 같은 소문자/숫자/언더스코어"; + 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 — persona 가 가져야 할 능력 (최소 1)"; + 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 — 단계 위험 등급"; + 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 — 위에서 정의한 role id 중 하나"; + 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 — 최소 10자. 이 phase 가 무엇을 해야 하는지"; + 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 =============== document.addEventListener("DOMContentLoaded", () => { @@ -751,5 +1029,7 @@ document.addEventListener("DOMContentLoaded", () => { $("#resume-btn").addEventListener("click", resumeRun); } else if (page === "conversation") { bootstrapConversationPage(); + } else if (page === "new-workflow") { + bootstrapWorkflowGenerator(); } }); diff --git a/my-deepagent/static/conversation.html b/my-deepagent/static/conversation.html index 146a865..6c00f7d 100644 --- a/my-deepagent/static/conversation.html +++ b/my-deepagent/static/conversation.html @@ -10,9 +10,10 @@

my-deepagent

diff --git a/my-deepagent/static/index.html b/my-deepagent/static/index.html index a313961..ea8681f 100644 --- a/my-deepagent/static/index.html +++ b/my-deepagent/static/index.html @@ -10,9 +10,10 @@

my-deepagent

@@ -23,6 +24,12 @@ 최근 50개 · 빈 화면이면 아래 "새 대화"를 누르세요 +
+ 👋 my-deepagent — OpenRouter 가성비 모델로 돌아가는 Claude Code 스타일 멀티턴 에이전트. + 대부분의 경우 아래 "새 대화 시작"만 누르면 됩니다. + 여러 단계 자동화가 필요하면 워크플로우, 템플릿 직접 만들기도 가능. +
+ diff --git a/my-deepagent/static/new-workflow.html b/my-deepagent/static/new-workflow.html new file mode 100644 index 0000000..bec210d --- /dev/null +++ b/my-deepagent/static/new-workflow.html @@ -0,0 +1,99 @@ + + + + + + my-deepagent · 워크플로우 템플릿 만들기 + + + +
+

my-deepagent

+ +
+
+ + + +
+

워크플로우 템플릿 만들기

+ phase 시퀀스 + role 정의 → YAML 저장 +
+ +
+ 📘 워크플로우 = phase 시퀀스
+ 예: "명세 작성" → "리뷰" → "검증" 처럼 단계별로 어떤 role(역할)이 어떤 + 산출물을 만들지 정의하는 파일입니다. 저장 후엔 워크플로우 실행 + 페이지의 드롭다운에 자동으로 등장합니다 (서버 재시작 불필요). +
+ +
+ + +
+

기본 정보

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+

+ Roles — phase 가 참조할 역할 정의 +

+
+ +
+ + +
+

+ Phases — 실제 실행되는 단계 순서 +

+
+ +
+ + +
+ + YAML 미리보기 — 저장될 파일 내용 + +

+      
+ +
+ + 취소 +
+
+
+ + + diff --git a/my-deepagent/static/new.html b/my-deepagent/static/new.html index 93f485b..ed7bfd5 100644 --- a/my-deepagent/static/new.html +++ b/my-deepagent/static/new.html @@ -3,56 +3,85 @@ - my-deepagent · 새 Run + my-deepagent · 워크플로우 실행

my-deepagent

-

새 Run 시작

- 워크플로우 + repo + 요구사항 +

워크플로우 실행 (고급 기능)

+ 사전 정의된 phase 시퀀스로 자동화된 작업 실행 +
+ +
+ 💡 자유 대화는 여기가 아닙니다. + 그냥 챗봇처럼 쓰고 싶다면 메인 페이지의 "새 대화 시작"을 눌러주세요. + 이 페이지는 여러 단계 (예: 명세 → 리뷰 → 검증) 가 정해진 순서로 자동 실행되는 워크플로우를 시작할 때 씁니다. +

+ 새 템플릿을 직접 만들고 싶다면 우상단 + 템플릿 만들기로 가세요.
- +
- +
- +
- - + +
-

Persona 오버라이드 (선택, 비우면 자동 선택)

-
+
+ + Persona 오버라이드 — 비우면 자동 선택 (고급) + +

+ 각 단계(role)에 어떤 persona(AI 모델 + 시스템 프롬프트)를 쓸지 직접 고르고 싶을 때만 채우세요. + 비워두면 capability 매칭으로 자동 선택됩니다. +

+
+
- + 취소
diff --git a/my-deepagent/static/run.html b/my-deepagent/static/run.html index ab09601..7a08cb4 100644 --- a/my-deepagent/static/run.html +++ b/my-deepagent/static/run.html @@ -10,9 +10,10 @@

my-deepagent

diff --git a/my-deepagent/static/runs.html b/my-deepagent/static/runs.html index 4dd8438..c17ab29 100644 --- a/my-deepagent/static/runs.html +++ b/my-deepagent/static/runs.html @@ -10,9 +10,10 @@

my-deepagent

diff --git a/my-deepagent/static/style.css b/my-deepagent/static/style.css index 474941b..2f14c9a 100644 --- a/my-deepagent/static/style.css +++ b/my-deepagent/static/style.css @@ -962,3 +962,134 @@ select { 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; +} diff --git a/my-deepagent/tests/integration/test_workflow_generator.py b/my-deepagent/tests/integration/test_workflow_generator.py new file mode 100644 index 0000000..dc17dbc --- /dev/null +++ b/my-deepagent/tests/integration/test_workflow_generator.py @@ -0,0 +1,202 @@ +"""v0.4 — Workflow generator UI + hot-reload tests. + +Covers: +1. POST /api/workflows persists a YAML under /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