iop/agent-task/node_centralized_mgmt/plan_1.log
toki b4c6550eab feat: edge/node architecture updates and agent-task integration
- Add node store implementation for edge app
- Add adapters factory for node app
- Update edge and node transport layers
- Update domain rules for edge and node
- Add bin scripts for edge and node
- Update configs and documentation
- Add agent-task node_centralized_mgmt directory
2026-05-03 10:51:29 +09:00

263 lines
8.4 KiB
Text

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