<!-- task=node_centralized_mgmt plan=1 tag=REVIEW_REFACTOR -->

# 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, 컴파일 오류 없음이 기대 결과이다.
