From d6b0194ae1617b2b54db0a50c6a6436e6a1d9781 Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 17 Jun 2026 08:07:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20control-plane-api-data-binding=20?= =?UTF-8?q?=EB=A7=88=EC=9D=BC=EC=8A=A4=ED=86=A4=20=EC=B6=94=EA=B0=80,=20fl?= =?UTF-8?q?utter-console-skeleton=EC=9D=84=20archive=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99,=20HTTP=20server=20route=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../milestones/flutter-console-skeleton.md | 29 +++++--- .../control-plane-product-surface/PHASE.md | 7 +- .../control-plane-api-data-binding.md | 71 +++++++++++++++++++ services/core/internal/httpserver/routes.go | 34 ++++++--- .../core/internal/httpserver/server_test.go | 39 ++++++++++ 5 files changed, 157 insertions(+), 23 deletions(-) rename agent-roadmap/{ => archive}/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md (71%) create mode 100644 agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md diff --git a/agent-roadmap/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md b/agent-roadmap/archive/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md similarity index 71% rename from agent-roadmap/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md rename to agent-roadmap/archive/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md index 4f1650c..21830c4 100644 --- a/agent-roadmap/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md +++ b/agent-roadmap/archive/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md @@ -12,7 +12,7 @@ ## 상태 -[진행중] +[완료] ## 승격 조건 @@ -21,6 +21,10 @@ ## 구현 잠금 - 상태: 해제 +- SDD: 불필요 +- SDD 문서: 없음 +- SDD 사유: 기존 Core health/readiness와 Flutter shell 경계를 사용하는 UI 스켈레톤이며 별도 API/schema 계약 변경을 강제하지 않는다. +- 잠금 해제 조건: 없음 - 결정 필요: 없음 ## 범위 @@ -37,20 +41,23 @@ OTO Console이 placeholder가 아니라 후속 기능을 얹을 수 있는 제품 화면 골격을 갖추도록 한다. -- [ ] [overview-status] Overview가 `OTO_SERVER_HTTP_URL` 기준 Core health/readiness 상태를 보여줄 수 있는 구조를 갖춘다. 검증: `make client-test` 또는 `cd apps/client && flutter test`에서 상태 표시 위젯 테스트가 통과한다. -- [ ] [section-surfaces] Runners, Pipelines/Jobs, Executions, Artifacts, Settings 섹션이 각각 독립 화면 스켈레톤과 빈 상태를 가진다. 검증: `cd packages/flutter/oto_console && flutter test`에서 navigation/section 렌더링 테스트가 통과한다. -- [ ] [api-port] Flutter UI가 Core API를 직접 호출할 구현 위치를 contract/adapter로 분리하고, 브라우저 CORS 또는 same-origin proxy 필요성을 작업 컨텍스트에 드러낸다. -- [ ] [preview-flow] 원격 preview 명령 `make client-run-web OTO_SERVER_HTTP_URL=http://toki-labs.com:18020` 기준으로 첫 화면이 Core API endpoint와 UI 스켈레톤을 안정적으로 렌더링한다. 검증: 원격 runner 또는 local smoke에서 Flutter web preview 접속 확인을 남긴다. +- [x] [overview-status] Overview가 `OTO_SERVER_HTTP_URL` 기준 Core health/readiness 상태를 보여줄 수 있는 구조를 갖춘다. 검증: `make client-test` 또는 `cd apps/client && flutter test`에서 상태 표시 위젯 테스트가 통과한다. +- [x] [section-surfaces] Runners, Pipelines/Jobs, Executions, Artifacts, Settings 섹션이 각각 독립 화면 스켈레톤과 빈 상태를 가진다. 검증: `cd packages/flutter/oto_console && flutter test`에서 navigation/section 렌더링 테스트가 통과한다. +- [x] [api-port] Flutter UI가 Core API를 직접 호출할 구현 위치를 contract/adapter로 분리하고, 브라우저 CORS 또는 same-origin proxy 필요성을 작업 컨텍스트에 드러낸다. +- [x] [preview-flow] 원격 preview 명령 `make client-run-web OTO_SERVER_HTTP_URL=http://toki-labs.com:18020` 기준으로 첫 화면이 Core API endpoint와 UI 스켈레톤을 안정적으로 렌더링한다. 검증: 원격 runner 또는 local smoke에서 Flutter web preview 접속 확인을 남긴다. ## 완료 리뷰 -- 상태: 없음 -- 요청일: 없음 -- 완료 근거: 없음 +- 상태: 승인됨 +- 요청일: 2026-06-17 +- 완료 근거: + - `agent-task/archive/2026/06/m-flutter-console-skeleton/01_preview_flow/complete.log`에 Overview health/readiness, section skeleton/empty state, remote preview smoke가 PASS로 기록되었다. + - `apps/client`와 `packages/flutter/oto_console` 테스트/분석 PASS, Chrome DevTools Protocol screenshot evidence가 같은 complete log에 남아 있다. + - 코드 확인 결과 Core connection adapter와 `OtoConsoleConfig` contract가 존재하며, Core HTTP route에 CORS/preflight 처리를 보강해 외부 preview에서 health/readiness 확인이 가능하다. - 리뷰 필요: - - [ ] 사용자가 완료 결과를 확인했다 - - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: 없음 + - [x] 사용자가 완료 결과를 확인했다 + - [x] archive 이동을 승인했다 +- 리뷰 코멘트: 사용자 확인 후 코드레벨 리뷰에서 발견된 CORS 보강을 완료했고, 종료 가능한 상태로 판단한다. ## 범위 제외 diff --git a/agent-roadmap/phase/control-plane-product-surface/PHASE.md b/agent-roadmap/phase/control-plane-product-surface/PHASE.md index cc18d5f..94ece8d 100644 --- a/agent-roadmap/phase/control-plane-product-surface/PHASE.md +++ b/agent-roadmap/phase/control-plane-product-surface/PHASE.md @@ -18,9 +18,12 @@ - [완료] Runner bootstrap, OTO binary, 노드 연결 - 경로: `agent-roadmap/archive/phase/control-plane-product-surface/milestones/cross-os-runner-bootstrap.md` - 요약: OTO Core bootstrap command와 bootstrap asset이 Linux, macOS, Windows에서 `oto` binary를 내려받고 runner/node로 Core에 연결할 수 있도록 확장한다. -- [진행중] Flutter 콘솔 스켈레톤 - - 경로: `agent-roadmap/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md` +- [완료] Flutter 콘솔 스켈레톤 + - 경로: `agent-roadmap/archive/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md` - 요약: bootstrap과 runner/node 연결 기준을 바탕으로 `apps/client`와 `packages/flutter/oto_console`에 Control Plane 주요 화면, 상태 표시, 빈 상태를 갖춘 UI 스켈레톤을 만든다. +- [계획] Control Plane API 데이터 바인딩 + - 경로: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md` + - 요약: UI 스켈레톤에 기존 OTO Core runner/job/execution/log/artifact API의 read path를 연결하고, 실제 데이터가 비어 있거나 실패할 때의 상태 표현을 검증한다. ## Phase 경계 diff --git a/agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md b/agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md new file mode 100644 index 0000000..9e40596 --- /dev/null +++ b/agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md @@ -0,0 +1,71 @@ +# Milestone: Control Plane API 데이터 바인딩 + +## 위치 + +- Roadmap: `agent-roadmap/ROADMAP.md` +- Phase: `agent-roadmap/phase/control-plane-product-surface/PHASE.md` + +## 목표 + +Flutter 콘솔 스켈레톤의 Overview, Runners, Pipelines/Jobs, Executions, Artifacts 화면을 기존 OTO Core HTTP API read path와 연결한다. +완료 후 사용자는 빈 상태뿐 아니라 실제 Core 응답, API 실패, loading/error 상태를 화면별로 확인할 수 있고, 후속 쓰기/운영 액션을 붙일 adapter 경계가 명확해야 한다. + +## 상태 + +[계획] + +## 승격 조건 + +- 없음 + +## 구현 잠금 + +- 상태: 해제 +- SDD: 불필요 +- SDD 문서: 없음 +- SDD 사유: 기존 OTO Core HTTP API를 소비하는 read-only UI 바인딩으로 시작하며, API/schema 변경은 범위 제외로 둔다. +- 잠금 해제 조건: 없음 +- 결정 필요: 없음 + +## 범위 + +- `apps/client`의 Core HTTP client/adapter와 `packages/flutter/oto_console`의 화면 contract 경계를 유지한다. +- Overview health/readiness 외에 runners, jobs/pipelines, executions, logs/artifacts read path를 화면별 상태 모델에 연결한다. +- 실제 데이터가 없을 때의 empty state, 요청 중 loading state, 실패/error state를 화면별로 구분한다. +- Core API 호출은 `OTO_SERVER_HTTP_URL` 기준으로 수행하고, 브라우저 CORS 보강 상태를 전제로 한다. + +## 기능 + +### Epic: [api-binding] Core API read binding + +UI 스켈레톤을 실제 Core 응답을 소비하는 제품 표면으로 전환한다. + +- [ ] [client-ports] Flutter client에 runners/jobs/executions/logs/artifacts 조회 adapter를 추가하고 `OtoConsoleConfig.serverHttpUrl`을 source of truth로 사용한다. 검증: `cd apps/client && flutter test`에서 adapter 상태 변환 테스트가 통과한다. +- [ ] [runner-surface] Runners 화면이 Core runner registry 응답을 loading/empty/error/data 상태로 표시한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 runners surface 테스트가 통과한다. +- [ ] [execution-surface] Pipelines/Jobs와 Executions 화면이 job/execution read model을 표시하고 로그/아티팩트 진입 위치를 제공한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 jobs/executions surface 테스트가 통과한다. +- [ ] [preview-data-smoke] 원격 preview `http://toki-labs.com:13020/` 기준으로 Core health/readiness와 최소 한 화면의 실제 API 상태가 렌더링된다. 검증: 원격 runner 또는 local smoke에서 browser/screenshot evidence와 `curl` evidence를 남긴다. + +## 완료 리뷰 + +- 상태: 없음 +- 요청일: 없음 +- 완료 근거: 없음 +- 리뷰 필요: + - [ ] 사용자가 완료 결과를 확인했다 + - [ ] archive 이동을 승인했다 +- 리뷰 코멘트: 없음 + +## 범위 제외 + +- job 생성, runner self-update, execution cancel/report 같은 쓰기 액션을 완성하지 않는다. +- auth, 권한, 감사 로그, durable persistence UI를 완성하지 않는다. +- 새로운 Core API/schema/proto 계약 변경은 이 Milestone의 기본 범위가 아니다. 필요성이 발견되면 별도 SDD 또는 후속 Milestone으로 분리한다. + +## 작업 컨텍스트 + +- 관련 경로: `apps/client/**`, `packages/flutter/oto_console/**`, `services/core/internal/httpserver/**` +- 표준선(선택): `OtoConsoleConfig.serverHttpUrl`을 API base URL로 유지하고, UI 패키지는 transport 구현보다 상태/표현 contract에 집중한다. +- 표준선(선택): Core HTTP route는 현재 CORS/preflight를 제공하므로 web preview는 cross-origin API smoke를 포함한다. +- 선행 작업: `Flutter 콘솔 스켈레톤` +- 후속 작업: 쓰기 액션, 인증/권한, 운영 대시보드 고도화 +- 확인 필요: 없음 diff --git a/services/core/internal/httpserver/routes.go b/services/core/internal/httpserver/routes.go index bbddb15..17265cf 100644 --- a/services/core/internal/httpserver/routes.go +++ b/services/core/internal/httpserver/routes.go @@ -12,16 +12,30 @@ import ( // This file exists to separate route registration logic from handler // implementation, making it easier to refactor handlers and DTOs later. func registerRoutes(mux *http.ServeMux, registry *runnerregistry.Registry, store *cicdstate.Store) { - mux.HandleFunc("/healthz", handleHealthz) - mux.HandleFunc("/readyz", handleReadyz) - mux.HandleFunc("/api/v1/runners/register", handleRunnerRegister(registry)) - mux.HandleFunc("/api/v1/runners/bootstrap-command", handleRunnerBootstrapCommand(registry)) - mux.HandleFunc("/api/v1/runners/{id}/heartbeat", handleRunnerHeartbeat(registry)) - mux.HandleFunc("/api/v1/runners/{id}/disconnect", handleRunnerDisconnect(registry)) - mux.HandleFunc("/api/v1/runners/{id}", handleGetRunner(registry)) - mux.HandleFunc("/bootstrap/oto-agent.sh", handleServeBootstrapScript(embeddedBootstrapProvider{})) - mux.HandleFunc("/bootstrap/oto-agent.ps1", handleServeBootstrapPs1(embeddedBootstrapPs1Provider{})) - mux.HandleFunc("/api/v1/", handleRouter(store, registry)) + mux.HandleFunc("/healthz", withCORS(handleHealthz)) + mux.HandleFunc("/readyz", withCORS(handleReadyz)) + mux.HandleFunc("/api/v1/runners/register", withCORS(handleRunnerRegister(registry))) + mux.HandleFunc("/api/v1/runners/bootstrap-command", withCORS(handleRunnerBootstrapCommand(registry))) + mux.HandleFunc("/api/v1/runners/{id}/heartbeat", withCORS(handleRunnerHeartbeat(registry))) + mux.HandleFunc("/api/v1/runners/{id}/disconnect", withCORS(handleRunnerDisconnect(registry))) + mux.HandleFunc("/api/v1/runners/{id}", withCORS(handleGetRunner(registry))) + mux.HandleFunc("/bootstrap/oto-agent.sh", withCORS(handleServeBootstrapScript(embeddedBootstrapProvider{}))) + mux.HandleFunc("/bootstrap/oto-agent.ps1", withCORS(handleServeBootstrapPs1(embeddedBootstrapPs1Provider{}))) + mux.HandleFunc("/api/v1/", withCORS(handleRouter(store, registry))) +} + +func withCORS(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Max-Age", "600") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next(w, r) + } } // Extract path segments after "/api/v1/" diff --git a/services/core/internal/httpserver/server_test.go b/services/core/internal/httpserver/server_test.go index dd6f9f6..e545539 100644 --- a/services/core/internal/httpserver/server_test.go +++ b/services/core/internal/httpserver/server_test.go @@ -102,6 +102,45 @@ func TestServerMux(t *testing.T) { } } +func TestServerMuxAddsCORSHeaders(t *testing.T) { + registry := runnerregistry.New() + server := NewServerWithRegistry(":0", registry) + mux := server.httpServer.Handler.(*http.ServeMux) + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + req.Header.Set("Origin", "http://toki-labs.com:13020") + rr := httptest.NewRecorder() + + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK) + } + if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Fatalf("Access-Control-Allow-Origin = %q, want *", got) + } +} + +func TestServerMuxHandlesCORSPreflight(t *testing.T) { + registry := runnerregistry.New() + server := NewServerWithRegistry(":0", registry) + mux := server.httpServer.Handler.(*http.ServeMux) + + req := httptest.NewRequest(http.MethodOptions, "/healthz", nil) + req.Header.Set("Origin", "http://toki-labs.com:13020") + req.Header.Set("Access-Control-Request-Method", http.MethodGet) + rr := httptest.NewRecorder() + + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %v, want %v", rr.Code, http.StatusNoContent) + } + if got := rr.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(got, http.MethodGet) { + t.Fatalf("Access-Control-Allow-Methods = %q, want GET included", got) + } +} + func TestHandleRunnerRegisterAcceptsAndStoresRunner(t *testing.T) { registry := runnerregistry.New() body := bytes.NewBufferString(`{