# CLI 어댑터 config 직렬화 버그 수정 + alias 중복 검사 추가 ## 이 파일을 읽는 구현 에이전트에게 각 항목의 체크리스트를 완료하면 즉시 체크 표시한다. 중간 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여 넣는다. ## 배경 이전 구현(plan=0)에서 `buildConfigPayload`가 CLI 어댑터의 `args`/`env` 필드를 `[]string` 타입으로 `structpb.NewStruct`에 전달한다. `structpb.NewValue`는 `[]interface{}`만 처리하므로 `[]string`은 `proto: invalid type: []string` 오류를 반환하고, 이 오류를 `_`로 무시해 CLI 설정 전체가 소실된다. 추가로 `LoadFromConfig`가 중복 alias를 검사하지 않아 동일 alias 등록 시 node ID가 충돌하고 한 노드가 silent하게 덮어써진다. --- ### [REVIEW_REFACTOR-1] buildConfigPayload: []string → structpb 오류 수정 #### 문제 `apps/edge/internal/transport/server.go:160-161`: ```go profiles[name] = map[string]any{ "command": p.Command, "args": p.Args, // []string — structpb 오류 발생 "env": p.Env, // []string — structpb 오류 발생 } ``` `p.Args`와 `p.Env`는 `[]string`이다. `structpb.NewValue`는 `[]interface{}`만 처리하고 `[]string`을 받으면 `proto: invalid type: []string` 오류를 반환한다. `st, _ := structpb.NewStruct(settings)` 로 오류를 무시하면 `st == nil`이 되어 CLI 어댑터 설정 전체가 소실되고, 노드에서 CLI 어댑터가 빈 profile 맵으로 초기화된다. #### 해결 방법 **`apps/edge/internal/transport/server.go` 변경:** 1. `buildConfigPayload`의 시그니처를 오류 반환형으로 변경. 2. CLI 프로필에서 `[]string` → `[]interface{}` 변환 헬퍼 추가. 3. `addAdapter`를 오류 반환형으로 변경해 `structpb.NewStruct` 오류를 전파. 4. `onNodeConnected` 내 `buildConfigPayload` 호출 시 오류 처리. Before (`buildConfigPayload`, L130-167): ```go func buildConfigPayload(rec *edgenode.NodeRecord) *iop.NodeConfigPayload { payload := &iop.NodeConfigPayload{ ... } addAdapter := func(typ string, enabled bool, settings map[string]any) { if !enabled { return } st, _ := structpb.NewStruct(settings) payload.Adapters = append(...) } // ... if rec.Adapters.CLI.Enabled { profiles := make(map[string]any) for name, p := range rec.Adapters.CLI.Profiles { profiles[name] = map[string]any{ "command": p.Command, "args": p.Args, "env": p.Env, } } addAdapter("cli", true, map[string]any{"profiles": profiles}) } return payload } ``` After: ```go func buildConfigPayload(rec *edgenode.NodeRecord) (*iop.NodeConfigPayload, error) { payload := &iop.NodeConfigPayload{ Runtime: &iop.NodeRuntimeConfig{ Concurrency: int32(rec.Runtime.Concurrency), WorkspaceRoot: rec.Runtime.WorkspaceRoot, }, } addAdapter := func(typ string, enabled bool, settings map[string]any) error { if !enabled { return nil } st, err := structpb.NewStruct(settings) if err != nil { return fmt.Errorf("buildConfigPayload: %s: %w", typ, err) } payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{ Type: typ, Enabled: true, Settings: st, }) return nil } if err := addAdapter("mock", true, nil); err != nil { return nil, err } if err := addAdapter("ollama", rec.Adapters.Ollama.Enabled, map[string]any{ "base_url": rec.Adapters.Ollama.BaseURL, }); err != nil { return nil, err } if err := addAdapter("vllm", rec.Adapters.Vllm.Enabled, map[string]any{ "endpoint": rec.Adapters.Vllm.Endpoint, }); err != nil { return nil, err } if rec.Adapters.CLI.Enabled { profiles := make(map[string]any) for name, p := range rec.Adapters.CLI.Profiles { profiles[name] = map[string]any{ "command": p.Command, "args": stringsToAny(p.Args), "env": stringsToAny(p.Env), } } if err := addAdapter("cli", true, map[string]any{"profiles": profiles}); err != nil { return nil, err } } return payload, nil } func stringsToAny(ss []string) []interface{} { out := make([]interface{}, len(ss)) for i, s := range ss { out[i] = s } return out } ``` `onNodeConnected` 내 호출부 변경 (L113-118): Before: ```go return &iop.RegisterResponse{ Accepted: true, NodeId: rec.ID, Alias: rec.Alias, Config: buildConfigPayload(rec), }, nil ``` After: ```go cfg, err := buildConfigPayload(rec) if err != nil { s.logger.Error("build config payload failed", zap.String("node_id", rec.ID), zap.Error(err)) return &iop.RegisterResponse{Accepted: false, Reason: "internal config error"}, nil } return &iop.RegisterResponse{ Accepted: true, NodeId: rec.ID, Alias: rec.Alias, Config: cfg, }, nil ``` 필요한 import 추가: `"fmt"` (buildConfigPayload 오류 포맷용) #### 수정 파일 및 체크리스트 - [x] `apps/edge/internal/transport/server.go` — `buildConfigPayload` 시그니처 변경, `addAdapter` 오류 반환, `stringsToAny` 추가, `onNodeConnected` 호출부 오류 처리 #### 테스트 작성 신규: `apps/edge/internal/transport/server_test.go` — `TestBuildConfigPayload_CLIArgsRoundtrip`: - `NodeRecord`에 CLI adapter enabled, profile에 `args: ["--foo", "--bar"]`, `env: ["KEY=val"]` 설정 - `buildConfigPayload(rec)` 호출 - 오류 없이 반환되는지 확인 - 반환된 payload의 CLI AdapterConfig Settings에서 args, env 값이 올바르게 저장되는지 확인 #### 중간 검증 ```bash go build ./apps/edge/internal/transport/... go test ./apps/edge/internal/transport/... ``` --- ### [REVIEW_REFACTOR-2] LoadFromConfig: 중복 alias 검사 추가 #### 문제 `apps/edge/internal/node/store.go:LoadFromConfig`가 중복 token은 검사하지만 중복 alias는 검사하지 않는다. Alias가 동일하면 `"node-" + alias`로 파생되는 ID가 충돌하고, `byID` 맵에서 후자가 전자를 덮어쓴다. Before (L66-86): ```go func LoadFromConfig(defs []config.NodeDefinition) (*NodeStore, error) { s := NewNodeStore() seen := make(map[string]bool) for i, d := range defs { if d.Token == "" { return nil, fmt.Errorf("node[%d] alias=%q: token must not be empty", i, d.Alias) } if seen[d.Token] { return nil, fmt.Errorf("node[%d] alias=%q: duplicate token", i, d.Alias) } seen[d.Token] = true // ... } return s, nil } ``` #### 해결 방법 After: ```go func LoadFromConfig(defs []config.NodeDefinition) (*NodeStore, error) { s := NewNodeStore() seenToken := make(map[string]bool) seenAlias := make(map[string]bool) for i, d := range defs { if d.Token == "" { return nil, fmt.Errorf("node[%d] alias=%q: token must not be empty", i, d.Alias) } if seenToken[d.Token] { return nil, fmt.Errorf("node[%d] alias=%q: duplicate token", i, d.Alias) } if seenAlias[d.Alias] { return nil, fmt.Errorf("node[%d] alias=%q: duplicate alias", i, d.Alias) } seenToken[d.Token] = true seenAlias[d.Alias] = true // ... } return s, nil } ``` #### 수정 파일 및 체크리스트 - [x] `apps/edge/internal/node/store.go` — `LoadFromConfig`에 alias 중복 검사 추가 #### 테스트 작성 기존 `apps/edge/internal/node/store_test.go`에 케이스 추가: - `TestLoadFromConfig_DuplicateAlias`: alias 동일, token 다른 두 정의 → 오류 반환 #### 중간 검증 ```bash go test ./apps/edge/internal/node/... ``` --- ## 수정 파일 요약 | 파일 | 항목 | |------|------| | `apps/edge/internal/transport/server.go` | REVIEW_REFACTOR-1 | | `apps/edge/internal/transport/server_test.go` (신규) | REVIEW_REFACTOR-1 | | `apps/edge/internal/node/store.go` | REVIEW_REFACTOR-2 | | `apps/edge/internal/node/store_test.go` | REVIEW_REFACTOR-2 | ## 최종 검증 ```bash go build ./... go test ./... ``` 모든 테스트 PASS, 컴파일 오류 없음이 기대 결과이다.