From a1031f197338fa0e285669652528064f96201f0c Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Wed, 15 Jul 2026 14:42:01 +0900 Subject: [PATCH] feat(vu-001): add Work Request Intake (Phase A) behind MAESTRO_WORKFLOW_ENABLED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the first VU-001 work-orchestration step: operators register a work request and approve/reject/cancel it before an agent starts, gated by the MAESTRO_WORKFLOW_ENABLED flag (default OFF) so existing flows are untouched when disabled. Server (maestro-server.js): - WorkRequest store (in-memory + persisted into the existing .maestro-workflows.json alongside work sessions; restored on restart) - GET/POST /api/work-requests, GET /:id, POST /:id/decision — 404 WORKFLOW_DISABLED when the flag is off - Validation with the planned error codes (WORK_REQUEST_INVALID, PROJECT_ID_INVALID, LANE_INDEX_INVALID, WORK_REQUEST_ALREADY_DECIDED) - WORK_REQUEST_CREATED / WORK_REQUEST_DECIDED websocket broadcasts - /health exposes workflow.enabled so the UI can gate the toggle UI: - useWorkRequests hook (flag probe via /health, CRUD, socket sync) - WorkRequestPanel (create form + request cards + approve/reject/cancel, aria-live summary, aria-expanded/controls) - Header "Requests" toggle (distinct from the existing "Work" console toggle), shown only when the flag is enabled Deviations from the 2026-03-15 plan (documented in PHASE_A plan): - Store integrated into .maestro-workflows.json + inline in the server instead of separate shared modules, to avoid clobbering Session Core - Project selector deferred to the multi-project phase; requests default to the active project Tests: 5 server regression cases + 2 UI regression cases. npm run qa and npm run smoke:lanes pass. Verified end-to-end against a live server (flag gating, create, approve, duplicate 409, validation, persistence). Co-Authored-By: Claude Opus 4.8 --- .env.example | 7 + .gitignore | 1 + .../PHASE_A_WORK_REQUEST_INTAKE_PLAN.md | 16 +- .../README.md | 3 +- maestro-server.js | 329 ++++++++++++++++++ src/App.jsx | 51 ++- src/App.work-request.ui.test.jsx | 101 ++++++ src/components/maestro/MaestroHeader.jsx | 17 + src/components/maestro/WorkRequestPanel.jsx | 291 ++++++++++++++++ src/hooks/useWorkRequests.js | 182 ++++++++++ tests/server-regression.test.mjs | 243 +++++++++++++ 11 files changed, 1238 insertions(+), 3 deletions(-) create mode 100644 src/App.work-request.ui.test.jsx create mode 100644 src/components/maestro/WorkRequestPanel.jsx create mode 100644 src/hooks/useWorkRequests.js diff --git a/.env.example b/.env.example index d301db7..0660686 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,10 @@ MAESTRO_HISTORY_MAX_ITEMS=300 # 승인 이력 영속 저장 파일 경로 (기본: .maestro-history.json) MAESTRO_HISTORY_STORE_PATH=.maestro-history.json + +# Work Console / Work Session / Work Request 영속 저장 파일 경로 (기본: .maestro-workflows.json) +MAESTRO_WORKFLOW_STORE_PATH=.maestro-workflows.json + +# VU-001 Work Request Intake (Phase A) 기능 플래그 (기본 OFF) +# true면 대시보드 헤더에 Requests 토글과 작업 요청 API(/api/work-requests)가 노출됩니다. +MAESTRO_WORKFLOW_ENABLED=false diff --git a/.gitignore b/.gitignore index 8b39eb8..ae923f9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ dist/ .env.*.local .maestro-projects.json .maestro-history.json +.maestro-workflows.json playwright-report/ test-results/ diff --git a/docs/version-upgrades/vu-001-openclaw-work-orchestration/PHASE_A_WORK_REQUEST_INTAKE_PLAN.md b/docs/version-upgrades/vu-001-openclaw-work-orchestration/PHASE_A_WORK_REQUEST_INTAKE_PLAN.md index ab552d1..257492c 100644 --- a/docs/version-upgrades/vu-001-openclaw-work-orchestration/PHASE_A_WORK_REQUEST_INTAKE_PLAN.md +++ b/docs/version-upgrades/vu-001-openclaw-work-orchestration/PHASE_A_WORK_REQUEST_INTAKE_PLAN.md @@ -2,7 +2,21 @@ 기준일: 2026-03-15 대상 트랙: `VU-001` -상태: 구현 계획 초안 +상태: 구현 완료 (2026-07-15) + +## 0. 구현 결과 요약 (2026-07-15) + +- 기능 플래그 `MAESTRO_WORKFLOW_ENABLED`(기본 OFF) 뒤에서 Work Request Intake 도입. +- 저장 전략은 계획의 별도 `shared` 모듈 대신, Session Core가 이미 사용하는 `.maestro-workflows.json`(`MAESTRO_WORKFLOW_STORE_PATH`)에 `workRequests` 배열을 통합하고 `maestro-server.js` 인라인 스토어로 구현(계획 대비 조정, 파일 충돌 회피). +- API: `GET/POST /api/work-requests`, `GET /api/work-requests/:id`, `POST /api/work-requests/:id/decision`. 플래그 OFF 시 404 `WORKFLOW_DISABLED`. +- 검증: `title`/`goal` 필수, `projectId`는 활성/등록 프로젝트만 허용, `laneIndex`는 프로젝트 레인 범위 내. 오류 코드는 계획의 표를 따름. +- 결정: `approve/reject/cancel` → `request_approved/request_rejected/cancelled`, 중복 결정 409. +- 브로드캐스트: `WORK_REQUEST_CREATED`, `WORK_REQUEST_DECIDED`. +- UI: 헤더 토글 라벨은 기존 `Work`(Work Console)와 충돌을 피해 **`Requests`**로 도입(플래그 ON일 때만 노출, `/health.workflow.enabled`로 감지). `WorkRequestPanel`에 생성 폼 + 요청 카드 목록 + 승인/반려/취소 액션. +- 프로젝트 선택 UI는 1차 단일 프로젝트 롤아웃 기준으로 활성 프로젝트 기본값으로 단순화(계획의 프로젝트 선택 드롭다운은 다중 프로젝트 단계에서 재도입). +- 테스트: 서버 회귀 5종(`tests/server-regression.test.mjs`), UI 회귀 2종(`src/App.work-request.ui.test.jsx`). `npm run qa` + `npm run smoke:lanes` 통과. + +원 계획(아래)은 설계 기준으로 보존한다. ## 1. 목적 diff --git a/docs/version-upgrades/vu-001-openclaw-work-orchestration/README.md b/docs/version-upgrades/vu-001-openclaw-work-orchestration/README.md index c2c2ca1..a61509b 100644 --- a/docs/version-upgrades/vu-001-openclaw-work-orchestration/README.md +++ b/docs/version-upgrades/vu-001-openclaw-work-orchestration/README.md @@ -72,11 +72,12 @@ Maestro를 다음 역할로 확장한다. - `git merge`는 decision이 아니라 executor action으로 분리한다. - goal 단위 실행 하네스는 `.agent/orchestration-*`와 `WORK_CONSOLE_BRANCH_HARNESS_PLAN.md`를 기준으로 추적한다. -### Phase A. Work Request Intake +### Phase A. Work Request Intake ✅ 완료 (2026-07-15) - 운영자가 작업 요청을 등록한다. - OpenClaw에 작업 착수 허가를 보내기 전 사람이 요청 자체를 승인/반려한다. - 이 단계에서는 기존 머지 승인 기능을 손대지 않는다. +- 구현: `MAESTRO_WORKFLOW_ENABLED` 플래그 뒤 `/api/work-requests` API + 헤더 `Requests` 패널. 상세는 [`PHASE_A_WORK_REQUEST_INTAKE_PLAN.md`](./PHASE_A_WORK_REQUEST_INTAKE_PLAN.md) 참조. ### Phase B. Plan Review diff --git a/maestro-server.js b/maestro-server.js index b759c75..c53df5e 100644 --- a/maestro-server.js +++ b/maestro-server.js @@ -85,6 +85,7 @@ const WORKFLOW_STORE_PATH = path.resolve( ROOT_DIR, process.env.MAESTRO_WORKFLOW_STORE_PATH || '.maestro-workflows.json', ); +const WORKFLOW_ENABLED = parseBoolean(process.env.MAESTRO_WORKFLOW_ENABLED, false); const persistedEnvValues = readEnvFile(ENV_PATH).values; let runtimeProjectState = createRuntimeProjectState({ path: process.env.MAIN_REPO_PATH || persistedEnvValues.MAIN_REPO_PATH || process.cwd(), @@ -655,6 +656,206 @@ function pruneClosedWorkSessions() { } } +// ── Work Request Intake (VU-001 Phase A) ───────────────────────────────────── +const WORK_REQUEST_PRIORITY = { + LOW: 'low', + NORMAL: 'normal', + HIGH: 'high', + URGENT: 'urgent', +}; + +const WORK_REQUEST_STATE = { + SUBMITTED: 'submitted', + REQUEST_APPROVED: 'request_approved', + REQUEST_REJECTED: 'request_rejected', + CANCELLED: 'cancelled', +}; + +const WORK_REQUEST_DECISION_STATE = { + approve: WORK_REQUEST_STATE.REQUEST_APPROVED, + reject: WORK_REQUEST_STATE.REQUEST_REJECTED, + cancel: WORK_REQUEST_STATE.CANCELLED, +}; + +const WORK_REQUEST_DEFAULT_LIMIT = 40; +const WORK_REQUEST_MAX_ITEMS = Math.min( + 500, + Math.max(20, parsePositiveInt(process.env.MAESTRO_WORK_REQUEST_MAX_ITEMS, 100)), +); + +const workRequestsById = new Map(); + +function createWorkRequestId() { + return `wrk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +function normalizeWorkRequestPriority(value) { + const allowed = new Set(Object.values(WORK_REQUEST_PRIORITY)); + return allowed.has(value) ? value : WORK_REQUEST_PRIORITY.NORMAL; +} + +function normalizeWorkRequestState(value) { + const allowed = new Set(Object.values(WORK_REQUEST_STATE)); + return allowed.has(value) ? value : WORK_REQUEST_STATE.SUBMITTED; +} + +function isTerminalWorkRequestState(state) { + return state !== WORK_REQUEST_STATE.SUBMITTED; +} + +function sanitizeWorkRequestStringList(value, { maxItems = 20, maxLength = 240 } = {}) { + if (!Array.isArray(value)) return []; + return value + .map((item) => sanitizeHistoryText(item || '', maxLength)) + .filter(Boolean) + .slice(0, maxItems); +} + +// Resolve a projectId to { id, laneCount } from the active runtime project or +// the registered project list. Returns null when the id is unknown. +function resolveWorkflowProject(projectId) { + const normalized = sanitizeHistoryText(projectId || '', 64); + if (!normalized || normalized === runtimeProjectState.id) { + return { + id: runtimeProjectState.id, + laneCount: sanitizeLaneCount(runtimeProjectState.laneCount, DEFAULT_LANE_COUNT), + }; + } + const registered = readProjectRegistry().find((project) => project.id === normalized); + if (!registered) return null; + return { + id: registered.id, + laneCount: sanitizeLaneCount(registered.laneCount, DEFAULT_LANE_COUNT), + }; +} + +function sortWorkRequests(items) { + return items + .slice() + .sort((a, b) => Date.parse(b.createdAt || 0) - Date.parse(a.createdAt || 0)); +} + +function pruneWorkRequests() { + sortWorkRequests(Array.from(workRequestsById.values())) + .slice(WORK_REQUEST_MAX_ITEMS) + .forEach((item) => { + workRequestsById.delete(item.workRequestId); + }); +} + +function listWorkRequests({ projectId = null, workflowState = null, limit = WORK_REQUEST_DEFAULT_LIMIT } = {}) { + const normalizedProjectId = sanitizeHistoryText(projectId || '', 64) || null; + const normalizedState = workflowState ? normalizeWorkRequestState(workflowState) : null; + return sortWorkRequests(Array.from(workRequestsById.values())) + .filter((item) => { + if (normalizedProjectId && item.projectId !== normalizedProjectId) return false; + if (normalizedState && item.workflowState !== normalizedState) return false; + return true; + }) + .slice(0, parseWorkLimit(limit, WORK_REQUEST_DEFAULT_LIMIT, WORK_REQUEST_MAX_ITEMS)); +} + +function getWorkRequest(workRequestId) { + const normalized = sanitizeHistoryText(workRequestId || '', 80); + if (!normalized) return null; + return workRequestsById.get(normalized) || null; +} + +// Returns { ok, code, error } or { ok: true, item }. +function createWorkRequest(input = {}) { + const title = sanitizeHistoryText(input.title || '', 120); + const goal = sanitizeHistoryText(input.goal || '', 1000); + if (!title || !goal) { + return { ok: false, code: 400, error: 'WORK_REQUEST_INVALID' }; + } + + const project = resolveWorkflowProject(input.projectId); + if (!project) { + return { ok: false, code: 400, error: 'PROJECT_ID_INVALID' }; + } + + let laneIndex = null; + if (input.laneIndex !== undefined && input.laneIndex !== null && input.laneIndex !== '') { + laneIndex = normalizeConfiguredLaneIndex(input.laneIndex, project.laneCount); + if (!laneIndex) { + return { ok: false, code: 400, error: 'LANE_INDEX_INVALID' }; + } + } + + const now = new Date().toISOString(); + const item = { + workRequestId: createWorkRequestId(), + projectId: project.id, + laneIndex, + requestedBy: sanitizeHistoryText(input.requestedBy || '', 64) || 'operator', + preferredAgent: sanitizeHistoryText(input.preferredAgent || '', 64) || 'openclaw', + title, + goal, + constraints: sanitizeWorkRequestStringList(input.constraints), + acceptanceCriteria: sanitizeWorkRequestStringList(input.acceptanceCriteria), + priority: normalizeWorkRequestPriority(input.priority), + targetBranch: sanitizeHistoryText(input.targetBranch || '', 200) || 'main', + workflowState: WORK_REQUEST_STATE.SUBMITTED, + createdAt: now, + updatedAt: now, + }; + + workRequestsById.set(item.workRequestId, item); + pruneWorkRequests(); + persistWorkflowStore(); + return { ok: true, item }; +} + +// Returns { ok, code, error } or { ok: true, item, decision }. +function decideWorkRequest(workRequestId, decision) { + const existing = getWorkRequest(workRequestId); + if (!existing) { + return { ok: false, code: 404, error: 'WORK_REQUEST_NOT_FOUND' }; + } + const normalizedDecision = sanitizeHistoryText(decision || '', 20).toLowerCase(); + const nextState = WORK_REQUEST_DECISION_STATE[normalizedDecision]; + if (!nextState) { + return { ok: false, code: 400, error: 'WORK_REQUEST_DECISION_INVALID' }; + } + if (isTerminalWorkRequestState(existing.workflowState)) { + return { ok: false, code: 409, error: 'WORK_REQUEST_ALREADY_DECIDED' }; + } + + const item = { + ...existing, + workflowState: nextState, + updatedAt: new Date().toISOString(), + }; + workRequestsById.set(item.workRequestId, item); + persistWorkflowStore(); + return { ok: true, item, decision: normalizedDecision }; +} + +function normalizeStoredWorkRequest(raw) { + if (!raw || typeof raw !== 'object') return null; + const workRequestId = sanitizeHistoryText(raw.workRequestId || '', 80); + const title = sanitizeHistoryText(raw.title || '', 120); + const goal = sanitizeHistoryText(raw.goal || '', 1000); + if (!workRequestId || !title || !goal) return null; + const now = new Date().toISOString(); + return { + workRequestId, + projectId: sanitizeHistoryText(raw.projectId || '', 64) || runtimeProjectState.id, + laneIndex: normalizeConfiguredLaneIndex(raw.laneIndex, MAX_LANE_COUNT), + requestedBy: sanitizeHistoryText(raw.requestedBy || '', 64) || 'operator', + preferredAgent: sanitizeHistoryText(raw.preferredAgent || '', 64) || 'openclaw', + title, + goal, + constraints: sanitizeWorkRequestStringList(raw.constraints), + acceptanceCriteria: sanitizeWorkRequestStringList(raw.acceptanceCriteria), + priority: normalizeWorkRequestPriority(raw.priority), + targetBranch: sanitizeHistoryText(raw.targetBranch || '', 200) || 'main', + workflowState: normalizeWorkRequestState(raw.workflowState), + createdAt: raw.createdAt || now, + updatedAt: raw.updatedAt || raw.createdAt || now, + }; +} + function persistWorkflowStore() { const storeDir = path.dirname(WORKFLOW_STORE_PATH); mkdirSync(storeDir, { recursive: true }); @@ -668,6 +869,7 @@ function persistWorkflowStore() { items.slice(-WORK_MESSAGE_MAX_ITEMS), ]), ), + workRequests: sortWorkRequests(Array.from(workRequestsById.values())), }; writeFileSync(WORKFLOW_STORE_PATH, JSON.stringify(payload, null, 2)); @@ -721,7 +923,16 @@ function loadWorkflowStore() { workMessagesBySessionId.set(normalizedSessionId, normalizedItems.slice(-WORK_MESSAGE_MAX_ITEMS)); }); + const workRequests = Array.isArray(parsed.workRequests) ? parsed.workRequests : []; + workRequests.forEach((raw) => { + const normalized = normalizeStoredWorkRequest(raw); + if (normalized) { + workRequestsById.set(normalized.workRequestId, normalized); + } + }); + pruneClosedWorkSessions(); + pruneWorkRequests(); } catch (error) { console.error('workflow store load failed:', error.message); } @@ -1023,7 +1234,9 @@ const server = http.createServer((req, res) => { laneCount: runtimeProjectState.laneCount, }, workflow: { + enabled: WORKFLOW_ENABLED, sessionCount: workSessionsById.size, + requestCount: workRequestsById.size, storePath: WORKFLOW_STORE_PATH, }, })); @@ -1325,6 +1538,122 @@ const server = http.createServer((req, res) => { return; } + // ── Work Request Intake routes (VU-001 Phase A, gated by MAESTRO_WORKFLOW_ENABLED) ── + if (pathname === '/api/work-requests' || pathname.startsWith('/api/work-requests/')) { + if (!WORKFLOW_ENABLED) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'WORKFLOW_DISABLED' })); + return; + } + if (!isRequestAuthorized(req)) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + } + + if (req.method === 'GET' && pathname === '/api/work-requests') { + const items = listWorkRequests({ + projectId: requestUrl.searchParams.get('projectId'), + workflowState: requestUrl.searchParams.get('workflowState'), + limit: requestUrl.searchParams.get('limit'), + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + items, + count: items.length, + maxItems: WORK_REQUEST_MAX_ITEMS, + })); + return; + } + + if (req.method === 'POST' && pathname === '/api/work-requests') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + try { + const data = JSON.parse(body); + const result = createWorkRequest({ + projectId: data.projectId, + laneIndex: data.laneIndex, + requestedBy: data.requestedBy, + preferredAgent: data.preferredAgent, + title: data.title, + goal: data.goal, + constraints: data.constraints, + acceptanceCriteria: data.acceptanceCriteria, + priority: data.priority, + targetBranch: data.targetBranch, + }); + + if (!result.ok) { + res.writeHead(result.code || 400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: result.error || 'WORK_REQUEST_INVALID' })); + return; + } + + broadcastToClients({ + event: 'WORK_REQUEST_CREATED', + item: result.item, + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, item: result.item })); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON body' })); + } + }); + return; + } + + const workRequestDecisionMatch = pathname.match(/^\/api\/work-requests\/([^/]+)\/decision$/); + if (req.method === 'POST' && workRequestDecisionMatch) { + const workRequestId = decodeURIComponent(workRequestDecisionMatch[1]); + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + try { + const data = JSON.parse(body); + const result = decideWorkRequest(workRequestId, data.decision); + + if (!result.ok) { + res.writeHead(result.code || 400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: result.error || 'WORK_REQUEST_DECISION_INVALID' })); + return; + } + + broadcastToClients({ + event: 'WORK_REQUEST_DECIDED', + item: result.item, + decision: result.decision, + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, item: result.item, decision: result.decision })); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON body' })); + } + }); + return; + } + + const workRequestDetailMatch = pathname.match(/^\/api\/work-requests\/([^/]+)$/); + if (req.method === 'GET' && workRequestDetailMatch) { + const workRequestId = decodeURIComponent(workRequestDetailMatch[1]); + const item = getWorkRequest(workRequestId); + if (!item) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'WORK_REQUEST_NOT_FOUND' })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ item })); + return; + } + if (req.method === 'GET' && pathname === '/api/work-sessions') { if (!isRequestAuthorized(req)) { res.writeHead(401, { 'Content-Type': 'application/json' }); diff --git a/src/App.jsx b/src/App.jsx index 1503338..e07df85 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -18,6 +18,7 @@ import useBachPlayer from './hooks/useBachPlayer.js'; import useProjectRegistryOps from './hooks/useProjectRegistryOps.js'; import useWorkConsoleShell from './hooks/useWorkConsoleShell.js'; import useWorkSessions from './hooks/useWorkSessions.js'; +import useWorkRequests from './hooks/useWorkRequests.js'; import useAgentRegistry from './hooks/useAgentRegistry.js'; import MaestroHeader from './components/maestro/MaestroHeader.jsx'; import ProjectTabs from './components/maestro/ProjectTabs.jsx'; @@ -28,6 +29,7 @@ import HistoryScorePanel from './components/maestro/HistoryScorePanel.jsx'; import AutoApproveOpsPanel from './components/maestro/AutoApproveOpsPanel.jsx'; import ProjectRegistryPanel from './components/maestro/ProjectRegistryPanel.jsx'; import WorkConsolePanel from './components/maestro/WorkConsolePanel.jsx'; +import WorkRequestPanel from './components/maestro/WorkRequestPanel.jsx'; export default function App() { const headerRef = useRef(null); @@ -208,6 +210,25 @@ export default function App() { onSelectedSessionChange: setSelectedWorkSessionId, }); + const [isWorkRequestPanelOpen, setIsWorkRequestPanelOpen] = useState(false); + const [selectedWorkRequestId, setSelectedWorkRequestId] = useState(null); + + const { + isWorkflowEnabled, + requests: workRequests, + selectedRequest: selectedWorkRequest, + requestError: workRequestError, + isRequestListLoading, + isSubmittingRequest, + createRequest, + decideRequest, + handleSocketEvent: handleWorkRequestsSocketEvent, + } = useWorkRequests({ + wsUrl: WS_URL, + selectedRequestId: selectedWorkRequestId, + onSelectedRequestChange: setSelectedWorkRequestId, + }); + const { agents, agentError, @@ -246,8 +267,9 @@ export default function App() { handleHistorySocketEvent(payload); handleAutoApproveSocketEvent(payload); handleWorkSessionsSocketEvent(payload); + handleWorkRequestsSocketEvent(payload); handleAgentRegistrySocketEvent(payload); - }, [handleAgentRegistrySocketEvent, handleAutoApproveSocketEvent, handleHistorySocketEvent, handleProjectSocketEvent, handleWorkSessionsSocketEvent]); + }, [handleAgentRegistrySocketEvent, handleAutoApproveSocketEvent, handleHistorySocketEvent, handleProjectSocketEvent, handleWorkRequestsSocketEvent, handleWorkSessionsSocketEvent]); const activeLaneCount = currentProject?.laneCount || DEFAULT_LANE_COUNT; const activeLanes = useMemo(() => getLaneDefinitions(activeLaneCount), [activeLaneCount]); @@ -460,6 +482,14 @@ export default function App() { closeWorkConsole(); }, [closeWorkConsole]); + const handleWorkRequestPanelToggle = useCallback(() => { + setIsWorkRequestPanelOpen((open) => !open); + }, [setIsWorkRequestPanelOpen]); + + const handleWorkRequestPanelClose = useCallback(() => { + setIsWorkRequestPanelOpen(false); + }, [setIsWorkRequestPanelOpen]); + const autoApproveStatusLabel = isAutoApproveAuthRequired ? 'Locked' : autoApproveStatus.config.enabled @@ -518,6 +548,9 @@ export default function App() { onToggleHistoryPanel={handleHistoryPanelToggle} isWorkConsoleOpen={isWorkConsoleOpen} onToggleWorkConsole={handleWorkConsoleToggle} + isWorkflowEnabled={isWorkflowEnabled === true} + isWorkRequestPanelOpen={isWorkRequestPanelOpen} + onToggleWorkRequestPanel={handleWorkRequestPanelToggle} /> + {isWorkflowEnabled === true && ( + + )} { + beforeEach(() => { + setupAppUiEnvironment(); + }); + + afterEach(() => { + teardownAppUiEnvironment(); + }); + + test('shows the Requests toggle only when the workflow feature is enabled and creates/decides a request', async () => { + const created = makeWorkRequest(); + const workRequests = []; + + globalThis.fetch = vi.fn(async (input, init = {}) => { + const url = String(input); + const method = (init.method || 'GET').toUpperCase(); + + if (url.includes('/health')) { + return { ok: true, status: 200, json: async () => ({ status: 'ok', workflow: { enabled: true } }) }; + } + if (url.includes('/api/work-requests') && method === 'POST' && url.endsWith('/decision')) { + const decided = { ...created, workflowState: 'request_approved' }; + const index = workRequests.findIndex((item) => item.workRequestId === created.workRequestId); + if (index >= 0) workRequests[index] = decided; + return { ok: true, status: 200, json: async () => ({ success: true, item: decided, decision: 'approve' }) }; + } + if (url.includes('/api/work-requests') && method === 'POST') { + workRequests.unshift(created); + return { ok: true, status: 200, json: async () => ({ success: true, item: created }) }; + } + if (url.includes('/api/work-requests')) { + return { ok: true, status: 200, json: async () => ({ items: workRequests, count: workRequests.length, maxItems: 100 }) }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }); + + await act(async () => { + render(); + }); + + const toggle = await screen.findByRole('button', { name: '작업 요청 패널 토글' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + await userEvent.click(toggle); + await waitFor(() => expect(toggle).toHaveAttribute('aria-expanded', 'true')); + + const panel = screen.getByTestId('work-request-panel'); + await userEvent.type(within(panel).getByLabelText('작업 제목'), '승인 이력 export 설계'); + await userEvent.type(within(panel).getByLabelText('작업 목표'), '히스토리 export API를 설계한다'); + await userEvent.click(within(panel).getByRole('button', { name: '작업 요청 등록' })); + + await waitFor(() => { + expect(within(panel).getByText('승인 이력 export 설계')).toBeInTheDocument(); + expect(within(panel).getByText('submitted')).toBeInTheDocument(); + }); + + await userEvent.click(within(panel).getByRole('button', { name: '작업 요청 승인' })); + + await waitFor(() => { + expect(within(panel).getByText('request_approved')).toBeInTheDocument(); + }); + }); + + test('hides the Requests toggle when the workflow feature is disabled', async () => { + await act(async () => { + render(); + }); + + // Default harness /health returns {} (no workflow.enabled), so the toggle stays hidden. + await waitFor(() => { + expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(0); + }); + expect(screen.queryByRole('button', { name: '작업 요청 패널 토글' })).toBeNull(); + }); +}); diff --git a/src/components/maestro/MaestroHeader.jsx b/src/components/maestro/MaestroHeader.jsx index bc4571a..0d64c78 100644 --- a/src/components/maestro/MaestroHeader.jsx +++ b/src/components/maestro/MaestroHeader.jsx @@ -45,6 +45,9 @@ export default function MaestroHeader({ onToggleHistoryPanel, isWorkConsoleOpen, onToggleWorkConsole, + isWorkflowEnabled = false, + isWorkRequestPanelOpen = false, + onToggleWorkRequestPanel, }) { const [shouldCollapsePanelControls, setShouldCollapsePanelControls] = useState(() => ( typeof window !== 'undefined' ? window.innerWidth < 1480 : false @@ -102,6 +105,17 @@ export default function MaestroHeader({ : 'border-gray-700 bg-gray-900/70 text-gray-300 hover:border-cyan-400/50 hover:text-cyan-100', onClick: onToggleWorkConsole, }, + ...(isWorkflowEnabled ? [{ + key: 'work-request', + label: 'Requests', + ariaLabel: '작업 요청 패널 토글', + panelId: 'work-request-panel', + isExpanded: isWorkRequestPanelOpen, + buttonClass: isWorkRequestPanelOpen + ? 'border-amber-400/60 bg-amber-500/20 text-amber-100' + : 'border-gray-700 bg-gray-900/70 text-gray-300 hover:border-amber-400/50 hover:text-amber-100', + onClick: onToggleWorkRequestPanel, + }] : []), { key: 'project', label: `Repo ${currentRuntimeProjectName || 'runtime'}`, @@ -156,10 +170,13 @@ export default function MaestroHeader({ isProjectAuthRequired, isProjectPanelOpen, isWorkConsoleOpen, + isWorkflowEnabled, + isWorkRequestPanelOpen, onToggleAutoApprovePanel, onToggleHistoryPanel, onToggleProjectPanel, onToggleWorkConsole, + onToggleWorkRequestPanel, ]); const renderPanelControlButton = (control, compact = false) => ( diff --git a/src/components/maestro/WorkRequestPanel.jsx b/src/components/maestro/WorkRequestPanel.jsx new file mode 100644 index 0000000..1042d41 --- /dev/null +++ b/src/components/maestro/WorkRequestPanel.jsx @@ -0,0 +1,291 @@ +import { useMemo, useState } from 'react'; +import { ClipboardList, X } from 'lucide-react'; + +const PANEL_ID = 'work-request-panel'; +const PANEL_TITLE_ID = 'work-request-panel-title'; +const PANEL_SUMMARY_ID = 'work-request-panel-summary'; + +const PRIORITY_OPTIONS = [ + { value: 'low', label: 'Low' }, + { value: 'normal', label: 'Normal' }, + { value: 'high', label: 'High' }, + { value: 'urgent', label: 'Urgent' }, +]; + +const STATE_STYLES = { + submitted: 'border-amber-500/40 bg-amber-500/10 text-amber-200', + request_approved: 'border-green-500/40 bg-green-500/10 text-green-200', + request_rejected: 'border-red-500/40 bg-red-500/10 text-red-200', + cancelled: 'border-gray-600 bg-gray-800/80 text-gray-300', +}; + +function toLines(value) { + return value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +export default function WorkRequestPanel({ + isOpen, + requests = [], + selectedRequestId, + selectedRequest, + isLoading, + isSubmitting, + error, + laneCount = 4, + onSelectRequest, + onCreateRequest, + onDecide, + onClose, +}) { + const [title, setTitle] = useState(''); + const [goal, setGoal] = useState(''); + const [priority, setPriority] = useState('normal'); + const [laneIndex, setLaneIndex] = useState(''); + const [constraints, setConstraints] = useState(''); + const [acceptanceCriteria, setAcceptanceCriteria] = useState(''); + + const laneOptions = useMemo( + () => Array.from({ length: Math.max(1, laneCount) }, (_, index) => index + 1), + [laneCount], + ); + + const summaryText = useMemo(() => { + if (selectedRequest) { + return `선택한 작업 요청 ${selectedRequest.title}, 상태 ${selectedRequest.workflowState}.`; + } + if (requests.length > 0) { + return `작업 요청 ${requests.length}건 중 하나를 선택할 수 있습니다.`; + } + return '등록된 작업 요청이 없습니다. 새 요청을 등록하세요.'; + }, [requests.length, selectedRequest]); + + const canSubmit = title.trim().length > 0 && goal.trim().length > 0 && !isSubmitting; + + const handleSubmit = async (event) => { + event.preventDefault(); + if (!canSubmit) return; + const created = await onCreateRequest?.({ + title: title.trim(), + goal: goal.trim(), + priority, + laneIndex: laneIndex ? Number(laneIndex) : undefined, + constraints: toLines(constraints), + acceptanceCriteria: toLines(acceptanceCriteria), + }); + if (created) { + setTitle(''); + setGoal(''); + setPriority('normal'); + setLaneIndex(''); + setConstraints(''); + setAcceptanceCriteria(''); + } + }; + + return ( +