iop/agent-task/node_centralized_mgmt/plan_0.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

1281 lines
36 KiB
Text

<!-- task=node_centralized_mgmt plan=0 tag=REFACTOR -->
# Jenkins 스타일 노드 중앙 관리 구조 리팩토링
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료하면 즉시 체크 표시한다. 각 중간 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여 넣는다. 모든 항목 완료 후 최종 검증을 실행하고 결과를 기록한다.
## 배경
현재 구조는 각 node가 자신의 `node.yaml`에서 ID, 어댑터 설정, 런타임 설정을 자체 선언한다. 이는 노드가 많아질수록 분산 관리의 복잡도를 높이고, edge가 어떤 노드가 연결해야 하는지 사전에 알 수 없어 위조·중복 접속을 막을 방법이 없다. Jenkins master/agent 모델과 동일하게, edge가 노드를 사전 등록(hash 토큰 발급)하고 모든 설정을 중앙 관리하며, node 바이너리는 `edge_addr + token` 두 값만 받아 기동하는 구조로 전환한다.
## 의존 관계 및 구현 순서
```
REFACTOR-1 (proto)
└─ REFACTOR-2 (config)
├─ REFACTOR-3 (edge: NodeStore + NodeEntry)
│ └─ REFACTOR-4 (edge: transport + bootstrap)
└─ REFACTOR-5 (node: transport 핸드셰이크)
└─ REFACTOR-6 (node: adapter factory + bootstrap)
└─ REFACTOR-7 (테스트 갱신)
```
---
### [REFACTOR-1] proto: RegisterRequest / RegisterResponse / NodeConfigPayload 추가
#### 문제
`proto/iop/runtime.proto`에 node → edge 등록 메시지가 없다. 현재 handshake는 edge가 먼저 `CapabilityRequest`를 보내는 방식이지만, 새 설계에서는 node가 먼저 `RegisterRequest{token}`을 보내고 edge가 `RegisterResponse{config}`로 응답한다.
#### 해결 방법
`proto/iop/runtime.proto`에 아래 메시지를 추가한다. `CapabilityRequest`/`CapabilityResponse`는 wire 호환성 유지를 위해 삭제하지 않고 그대로 남긴다.
```protobuf
// 추가할 메시지 (runtime.proto 기존 내용 뒤에 append)
// RegisterRequest is sent by node to edge immediately on connect.
message RegisterRequest {
string token = 1;
}
// RegisterResponse is sent by edge to node in response to RegisterRequest.
message RegisterResponse {
bool accepted = 1;
string node_id = 2;
string alias = 3;
string reason = 4; // rejection reason
NodeConfigPayload config = 5;
}
// NodeConfigPayload carries all configuration edge pushes to the node.
message NodeConfigPayload {
repeated AdapterConfig adapters = 1;
NodeRuntimeConfig runtime = 2;
}
// AdapterConfig describes one adapter to enable on the node.
message AdapterConfig {
string type = 1; // "mock" | "ollama" | "vllm" | "cli"
bool enabled = 2;
google.protobuf.Struct settings = 3;
}
// NodeRuntimeConfig is the runtime tuning pushed to the node.
message NodeRuntimeConfig {
int32 concurrency = 1;
string workspace_root = 2;
}
```
`make proto`를 실행해 `proto/gen/iop/runtime.pb.go`를 재생성한다. 생성 파일은 직접 수정하지 않는다.
#### 수정 파일 및 체크리스트
- [x] `proto/iop/runtime.proto` — 위 5개 메시지 추가
- [x] `proto/gen/iop/runtime.pb.go` — `make proto`로 재생성 (직접 수정 금지)
#### 테스트 작성
skip — proto 파일 자체는 컴파일 검증으로 충분하다.
#### 중간 검증
```bash
make proto
go build ./...
```
컴파일 오류 없이 통과해야 한다.
---
### [REFACTOR-2] packages/config: NodeConfig 단순화 + EdgeConfig에 노드 정의 추가
#### 문제
`packages/config/config.go`의 `NodeConfig`(L7-16)에 `NodeInfo`, `RuntimeConf`, `SQLiteConf`, `AdaptersConf`가 있다. 이 값들은 이제 edge가 관리하므로 node config에서 제거한다. `EdgeConfig`(L18-23)에는 어떤 노드가 연결해야 하는지에 대한 정의가 전혀 없으므로 `Nodes []NodeDefinition`을 추가한다.
#### 해결 방법
**`packages/config/config.go` 변경:**
Before (`NodeConfig`, L7-16):
```go
type NodeConfig struct {
Node NodeInfo `mapstructure:"node" yaml:"node"`
Transport TransportConf `mapstructure:"transport" yaml:"transport"`
TLS TLSConf `mapstructure:"tls" yaml:"tls"`
Runtime RuntimeConf `mapstructure:"runtime" yaml:"runtime"`
SQLite SQLiteConf `mapstructure:"sqlite" yaml:"sqlite"`
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
Adapters AdaptersConf `mapstructure:"adapters" yaml:"adapters"`
}
```
After:
```go
type NodeConfig struct {
Transport TransportConf `mapstructure:"transport" yaml:"transport"`
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
}
```
Before (`TransportConf`, L34-36):
```go
type TransportConf struct {
EdgeAddr string `mapstructure:"edge_addr" yaml:"edge_addr"`
}
```
After:
```go
type TransportConf struct {
EdgeAddr string `mapstructure:"edge_addr" yaml:"edge_addr"`
Token string `mapstructure:"token" yaml:"token"`
}
```
Before (`EdgeConfig`, L18-23):
```go
type EdgeConfig struct {
Server EdgeServerConf `mapstructure:"server" yaml:"server"`
TLS TLSConf `mapstructure:"tls" yaml:"tls"`
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
}
```
After:
```go
type EdgeConfig struct {
Server EdgeServerConf `mapstructure:"server" yaml:"server"`
TLS TLSConf `mapstructure:"tls" yaml:"tls"`
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
Nodes []NodeDefinition `mapstructure:"nodes" yaml:"nodes"`
}
```
새 타입 `NodeDefinition` 추가 (EdgeConfig 아래):
```go
// NodeDefinition is the edge-side record for a pre-registered node.
type NodeDefinition struct {
Alias string `mapstructure:"alias" yaml:"alias"`
Token string `mapstructure:"token" yaml:"token"`
Adapters AdaptersConf `mapstructure:"adapters" yaml:"adapters"`
Runtime RuntimeConf `mapstructure:"runtime" yaml:"runtime"`
}
```
`setDefaults`(L117-125) 변경: node 관련 기본값 제거, token 기본값 없음 (필수값).
Before:
```go
func setDefaults(v *viper.Viper) {
v.SetDefault("transport.edge_addr", "localhost:9090")
v.SetDefault("runtime.concurrency", 4)
v.SetDefault("runtime.workspace_root", "/tmp/iop/workspace")
v.SetDefault("sqlite.dsn", "file:iop.db?cache=shared&mode=rwc")
v.SetDefault("logging.level", "info")
v.SetDefault("metrics.port", 9091)
v.SetDefault("tls.enabled", false)
}
```
After:
```go
func setDefaults(v *viper.Viper) {
v.SetDefault("transport.edge_addr", "localhost:9090")
v.SetDefault("logging.level", "info")
v.SetDefault("metrics.port", 9091)
}
```
**`configs/node.yaml` 변경:**
```yaml
transport:
edge_addr: "localhost:9090"
token: "changeme"
logging:
level: "info"
metrics:
port: 9091
```
**`configs/edge.yaml` 변경:**
```yaml
server:
listen: "0.0.0.0:9090"
tls:
enabled: false
logging:
level: "info"
metrics:
port: 9092
nodes:
- alias: "local-node"
token: "changeme"
adapters:
ollama:
enabled: false
base_url: "http://localhost:11434"
vllm:
enabled: false
endpoint: "http://localhost:8000"
cli:
enabled: false
profiles:
claude:
command: "claude"
args: []
env: []
runtime:
concurrency: 4
workspace_root: "/tmp/iop/workspace"
```
#### 수정 파일 및 체크리스트
- [x] `packages/config/config.go` — NodeConfig 단순화, TransportConf에 Token 추가, NodeDefinition 추가, EdgeConfig에 Nodes 추가, setDefaults 정리
- [x] `configs/node.yaml` — transport + token + logging + metrics만 남김
- [x] `configs/edge.yaml` — nodes 섹션 추가
- [x] `NodeInfo`, `RuntimeConf`, `SQLiteConf` 타입은 node가 더 이상 직접 사용하지 않지만 타입 자체는 config.go에 남겨 edge의 `NodeDefinition`에서 재사용한다
#### 테스트 작성
skip — config는 이후 bootstrap 변경 후 통합 검증한다.
#### 중간 검증
```bash
go build ./packages/config/...
```
---
### [REFACTOR-3] edge: NodeStore 신설 + NodeEntry 갱신
#### 문제
edge가 노드를 사전에 알 수 있는 저장소가 없다. `NodeEntry`(registry.go L13-17)에 alias, config 필드가 없다.
#### 해결 방법
**신규 파일 `apps/edge/internal/node/store.go` 작성:**
```go
package node
import (
"fmt"
"sync"
"iop/packages/config"
)
// NodeRecord is the pre-registered node definition stored in edge.
type NodeRecord struct {
ID string
Alias string
Token string
Adapters config.AdaptersConf
Runtime config.RuntimeConf
}
// NodeStore holds pre-registered node definitions, keyed by token.
type NodeStore struct {
mu sync.RWMutex
byToken map[string]*NodeRecord
byID map[string]*NodeRecord
}
func NewNodeStore() *NodeStore {
return &NodeStore{
byToken: make(map[string]*NodeRecord),
byID: make(map[string]*NodeRecord),
}
}
func (s *NodeStore) Add(rec *NodeRecord) {
s.mu.Lock()
defer s.mu.Unlock()
s.byToken[rec.Token] = rec
s.byID[rec.ID] = rec
}
func (s *NodeStore) FindByToken(token string) (*NodeRecord, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
r, ok := s.byToken[token]
return r, ok
}
func (s *NodeStore) FindByID(id string) (*NodeRecord, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
r, ok := s.byID[id]
return r, ok
}
// LoadFromConfig seeds the store from EdgeConfig.Nodes.
// ID is derived as "node-{alias}" if not provided — caller should use UUIDs in production.
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
s.Add(&NodeRecord{
ID: "node-" + d.Alias,
Alias: d.Alias,
Token: d.Token,
Adapters: d.Adapters,
Runtime: d.Runtime,
})
}
return s, nil
}
```
**`apps/edge/internal/node/registry.go` 변경:**
`NodeEntry`(L13-17) 변경:
Before:
```go
type NodeEntry struct {
NodeID string
Client *toki.TcpClient
Adapters []*iop.AdapterInfo
}
```
After:
```go
type NodeEntry struct {
NodeID string
Alias string
Client *toki.TcpClient
}
```
`Adapters []*iop.AdapterInfo` 제거 — adapter 정보는 edge의 NodeStore에서 관리하므로 런타임 registry에 중복 보관할 필요가 없다.
#### 수정 파일 및 체크리스트
- [x] `apps/edge/internal/node/store.go` — 신규 작성
- [x] `apps/edge/internal/node/registry.go` — NodeEntry에서 Adapters 제거, Alias 추가
#### 테스트 작성
신규 파일 `apps/edge/internal/node/store_test.go` 작성:
- `TestLoadFromConfig_Success`: alias 2개 정의 → `FindByToken` 조회 성공
- `TestLoadFromConfig_DuplicateToken`: 중복 token → 오류 반환
- `TestLoadFromConfig_EmptyToken`: token 빈 값 → 오류 반환
#### 중간 검증
```bash
go test ./apps/edge/internal/node/...
```
---
### [REFACTOR-4] edge: transport 핸드셰이크 변경 + bootstrap 재배선
#### 문제
`apps/edge/internal/transport/server.go`의 `onNodeConnected`(L74-112)이 연결 직후 `CapabilityRequest`를 보낸다. 새 설계에서는 node가 먼저 `RegisterRequest{token}`을 보내고 edge가 검증 후 `RegisterResponse{config}`를 응답한다.
`apps/edge/internal/bootstrap/module.go`에 `NodeStore`가 없다.
#### 해결 방법
**`apps/edge/internal/transport/server.go` 전면 변경:**
`Server` 구조체에 `nodeStore` 추가:
Before (L36-42):
```go
type Server struct {
tcp *toki.TcpServer
listen string
registry *edgenode.Registry
logger *zap.Logger
}
```
After:
```go
type Server struct {
tcp *toki.TcpServer
listen string
registry *edgenode.Registry
nodeStore *edgenode.NodeStore
logger *zap.Logger
}
```
`NewServer` 시그니처 변경:
Before (L44):
```go
func NewServer(listen string, registry *edgenode.Registry, logger *zap.Logger) (*Server, error) {
```
After:
```go
func NewServer(listen string, registry *edgenode.Registry, nodeStore *edgenode.NodeStore, logger *zap.Logger) (*Server, error) {
```
`edgeParserMap`(L23-34) 변경: `CapabilityResponse` 제거, `RegisterRequest` 추가:
Before:
```go
func edgeParserMap() toki.ParserMap {
return toki.ParserMap{
toki.TypeNameOf(&iop.RunEvent{}): func(b []byte) (proto.Message, error) {
m := &iop.RunEvent{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.CapabilityResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.CapabilityResponse{}
return m, proto.Unmarshal(b, m)
},
}
}
```
After:
```go
func edgeParserMap() toki.ParserMap {
return toki.ParserMap{
toki.TypeNameOf(&iop.RunEvent{}): func(b []byte) (proto.Message, error) {
m := &iop.RunEvent{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.RegisterRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.RegisterRequest{}
return m, proto.Unmarshal(b, m)
},
}
}
```
`onNodeConnected`(L74-112) 전면 재작성:
Before (핵심 로직):
```go
func (s *Server) onNodeConnected(client *toki.TcpClient) {
// ... RunEvent listener 등록 ...
go func() {
resp, err := toki.SendRequestTyped[*iop.CapabilityRequest, *iop.CapabilityResponse](...)
// ... registry.Register(entry) ...
}()
}
```
After:
```go
func (s *Server) onNodeConnected(client *toki.TcpClient) {
s.logger.Info("node connection established")
toki.AddListenerTyped[*iop.RunEvent](&client.Communicator, func(e *iop.RunEvent) {
s.logger.Debug("run event received",
zap.String("run_id", e.GetRunId()),
zap.String("type", e.GetType()),
)
})
toki.AddRequestListenerTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator,
func(req *iop.RegisterRequest) (*iop.RegisterResponse, error) {
rec, ok := s.nodeStore.FindByToken(req.GetToken())
if !ok {
s.logger.Warn("unknown token", zap.String("token_prefix", safePrefix(req.GetToken())))
return &iop.RegisterResponse{Accepted: false, Reason: "unknown token"}, nil
}
entry := &edgenode.NodeEntry{
NodeID: rec.ID,
Alias: rec.Alias,
Client: client,
}
client.AddDisconnectListener(func(_ *toki.TcpClient) {
s.registry.Unregister(rec.ID)
s.logger.Info("node unregistered", zap.String("node_id", rec.ID))
})
s.registry.Register(entry)
s.logger.Info("node registered",
zap.String("node_id", rec.ID),
zap.String("alias", rec.Alias),
)
return &iop.RegisterResponse{
Accepted: true,
NodeId: rec.ID,
Alias: rec.Alias,
Config: buildConfigPayload(rec),
}, nil
},
)
}
func safePrefix(s string) string {
if len(s) > 8 {
return s[:8] + "..."
}
return s
}
func buildConfigPayload(rec *edgenode.NodeRecord) *iop.NodeConfigPayload {
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) {
if !enabled {
return
}
st, _ := structpb.NewStruct(settings)
payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{
Type: typ,
Enabled: true,
Settings: st,
})
}
addAdapter("mock", true, nil) // mock is always enabled
addAdapter("ollama", rec.Adapters.Ollama.Enabled, map[string]any{
"base_url": rec.Adapters.Ollama.BaseURL,
})
addAdapter("vllm", rec.Adapters.Vllm.Enabled, map[string]any{
"endpoint": rec.Adapters.Vllm.Endpoint,
})
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
}
```
필요한 import 추가: `"google.golang.org/protobuf/types/known/structpb"`
**`apps/edge/internal/bootstrap/module.go` 변경:**
`NewServer` 호출부에 `nodeStore` 추가, `NodeStore` 생성 Provider 추가:
Before (L28-29):
```go
func(cfg *config.EdgeConfig, reg *edgenode.Registry, logger *zap.Logger) (*transport.Server, error) {
return transport.NewServer(cfg.Server.Listen, reg, logger)
},
```
After:
```go
func() *edgenode.NodeStore { return edgenode.NewNodeStore() }, // placeholder; seeded in Invoke
func(cfg *config.EdgeConfig, reg *edgenode.Registry, ns *edgenode.NodeStore, logger *zap.Logger) (*transport.Server, error) {
return transport.NewServer(cfg.Server.Listen, reg, ns, logger)
},
```
`fx.Invoke`에서 `NodeStore`를 config으로 시드:
```go
fx.Invoke(func(lc fx.Lifecycle, srv *transport.Server, ns *edgenode.NodeStore, cfg *config.EdgeConfig, logger *zap.Logger) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
seeded, err := edgenode.LoadFromConfig(cfg.Nodes)
if err != nil {
return fmt.Errorf("edge: seed node store: %w", err)
}
for _, rec := range seeded.All() {
ns.Add(rec)
}
// ... start server + metrics ...
},
})
})
```
`NodeStore.All()` 메서드도 `store.go`에 추가한다:
```go
func (s *NodeStore) All() []*NodeRecord {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*NodeRecord, 0, len(s.byID))
for _, r := range s.byID {
out = append(out, r)
}
return out
}
```
#### 수정 파일 및 체크리스트
- [x] `apps/edge/internal/transport/server.go` — edgeParserMap 변경, Server 구조체 변경, NewServer 시그니처 변경, onNodeConnected 재작성, buildConfigPayload 추가, safePrefix 추가
- [x] `apps/edge/internal/bootstrap/module.go` — NodeStore provider 추가, NewServer 호출 갱신, OnStart에서 NodeStore 시드
- [x] `apps/edge/internal/node/store.go` — All() 메서드 추가 (REFACTOR-3에서 작성한 파일에 추가)
#### 테스트 작성
skip — integration_test.go는 REFACTOR-7에서 갱신한다.
#### 중간 검증
```bash
go build ./apps/edge/...
```
---
### [REFACTOR-5] node: transport 핸드셰이크 변경 + DialEdge 시그니처 변경
#### 문제
`apps/node/internal/transport/session.go`의 `Handler` 인터페이스(L16-19)에 `OnCapabilityRequest`가 있고, `newSession`(L29-64)에 `CapabilityRequest` 리스너가 등록되어 있다. `DialEdge`(client.go L19-37)가 handler와 nodeID를 파라미터로 받는다.
새 설계에서 node는 연결 직후 `RegisterRequest{token}`을 보내고 `RegisterResponse`를 받은 뒤에야 `node.Node`를 생성한다. `DialEdge` 시점에는 아직 handler가 없으므로 Session이 handler를 나중에 주입받을 수 있어야 한다.
#### 해결 방법
**`apps/node/internal/transport/session.go` 변경:**
`Handler` 인터페이스(L16-19)에서 `OnCapabilityRequest` 제거:
Before:
```go
type Handler interface {
OnRunRequest(ctx context.Context, sess *Session, req *iop.RunRequest) error
OnCapabilityRequest(ctx context.Context, sess *Session) (*iop.CapabilityResponse, error)
OnCancel(ctx context.Context, sess *Session, req *iop.CancelRequest) error
}
```
After:
```go
type Handler interface {
OnRunRequest(ctx context.Context, sess *Session, req *iop.RunRequest) error
OnCancel(ctx context.Context, sess *Session, req *iop.CancelRequest) error
}
```
`Session` 구조체에 mutable handler 추가 (nodeID 제거):
Before (L22-27):
```go
type Session struct {
client *toki.TcpClient
nodeID string
logger *zap.Logger
cancelFns sync.Map
}
```
After:
```go
type Session struct {
client *toki.TcpClient
logger *zap.Logger
mu sync.RWMutex
handler Handler
cancelFns sync.Map
}
```
`SetHandler` 메서드 추가:
```go
// SetHandler attaches the message handler. Called after registration completes.
func (s *Session) SetHandler(h Handler) {
s.mu.Lock()
s.handler = h
s.mu.Unlock()
}
```
`newSession` 시그니처에서 `handler Handler`, `nodeID string` 제거, CapabilityRequest 리스너 제거, handler 호출 시 nil 체크:
Before (L29):
```go
func newSession(client *toki.TcpClient, handler Handler, nodeID string, logger *zap.Logger) *Session {
```
After:
```go
func newSession(client *toki.TcpClient, logger *zap.Logger) *Session {
s := &Session{client: client, logger: logger}
toki.AddListenerTyped[*iop.RunRequest](&client.Communicator, func(req *iop.RunRequest) {
go func() {
s.mu.RLock()
h := s.handler
s.mu.RUnlock()
if h == nil {
return
}
if err := h.OnRunRequest(context.Background(), s, req); err != nil {
logger.Warn("run request error",
zap.String("run_id", req.GetRunId()),
zap.Error(err),
)
}
}()
})
toki.AddListenerTyped[*iop.CancelRequest](&client.Communicator, func(req *iop.CancelRequest) {
s.mu.RLock()
h := s.handler
s.mu.RUnlock()
if h == nil {
return
}
if err := h.OnCancel(context.Background(), s, req); err != nil {
logger.Warn("cancel error",
zap.String("run_id", req.GetRunId()),
zap.Error(err),
)
}
})
client.AddDisconnectListener(func(_ *toki.TcpClient) {
logger.Info("disconnected from edge")
})
return s
}
```
**`apps/node/internal/transport/parser.go` 변경:**
`nodeParserMap`에서 `CapabilityRequest` 제거, `RegisterResponse` 추가:
Before:
```go
func nodeParserMap() toki.ParserMap {
return toki.ParserMap{
toki.TypeNameOf(&iop.RunRequest{}): ...,
toki.TypeNameOf(&iop.CancelRequest{}): ...,
toki.TypeNameOf(&iop.CapabilityRequest{}): ...,
}
}
```
After:
```go
func nodeParserMap() toki.ParserMap {
return toki.ParserMap{
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.RunRequest{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.CancelRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.CancelRequest{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.RegisterResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.RegisterResponse{}
return m, proto.Unmarshal(b, m)
},
}
}
```
**`apps/node/internal/transport/client.go` 전면 재작성:**
Before (전체):
```go
const (
heartbeatIntervalSec = 30
heartbeatWaitSec = 10
)
func DialEdge(ctx context.Context, addr string, handler Handler, nodeID string, logger *zap.Logger) (*Session, error) {
// ...
sess := newSession(client, handler, nodeID, logger)
// ...
return sess, nil
}
```
After:
```go
package transport
import (
"context"
"fmt"
"net"
"strconv"
"time"
toki "git.toki-labs.com/toki/common-proto-socket/go"
"go.uber.org/zap"
iop "iop/proto/gen/iop"
)
const (
heartbeatIntervalSec = 30
heartbeatWaitSec = 10
registerTimeout = 10 * time.Second
)
// RegisterResult is returned by DialEdge after successful registration.
type RegisterResult struct {
Session *Session
NodeID string
Alias string
Config *iop.NodeConfigPayload
}
// DialEdge connects to edge, performs the registration handshake, and returns
// a RegisterResult. Call result.Session.SetHandler after creating node.Node.
func DialEdge(ctx context.Context, addr, token string, logger *zap.Logger) (*RegisterResult, error) {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("transport: invalid addr %q: %w", addr, err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("transport: invalid port %q: %w", portStr, err)
}
client, err := toki.DialTcp(ctx, host, port, heartbeatIntervalSec, heartbeatWaitSec, nodeParserMap())
if err != nil {
return nil, fmt.Errorf("transport: dial edge %s: %w", addr, err)
}
resp, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator,
&iop.RegisterRequest{Token: token},
registerTimeout,
)
if err != nil {
_ = client.Close()
return nil, fmt.Errorf("transport: register: %w", err)
}
if !resp.GetAccepted() {
_ = client.Close()
return nil, fmt.Errorf("transport: register rejected: %s", resp.GetReason())
}
sess := newSession(client, logger)
logger.Info("registered with edge",
zap.String("node_id", resp.GetNodeId()),
zap.String("alias", resp.GetAlias()),
)
return &RegisterResult{
Session: sess,
NodeID: resp.GetNodeId(),
Alias: resp.GetAlias(),
Config: resp.GetConfig(),
}, nil
}
```
#### 수정 파일 및 체크리스트
- [x] `apps/node/internal/transport/session.go` — Handler 인터페이스에서 OnCapabilityRequest 제거, Session에 mu + handler 추가 (nodeID 제거), SetHandler 추가, newSession 재작성
- [x] `apps/node/internal/transport/parser.go` — CapabilityRequest 제거, RegisterResponse 추가
- [x] `apps/node/internal/transport/client.go` — RegisterResult 추가, DialEdge 전면 재작성
#### 테스트 작성
skip — integration_test.go는 REFACTOR-7에서 갱신한다.
#### 중간 검증
```bash
go build ./apps/node/internal/transport/...
```
---
### [REFACTOR-6] node: adapter factory + bootstrap 재구성
#### 문제
`apps/node/internal/bootstrap/module.go`가 `config.NodeConfig`의 어댑터 설정으로 어댑터를 정적 초기화한다(L38-54). 새 설계에서는 `DialEdge` 이후 수신된 `NodeConfigPayload`를 기반으로 동적 초기화해야 한다. 또한 `node.New`(node.go L31-45)가 `*config.NodeConfig`를 받아 `nodeID`를 추출하는데, ID는 이제 registration 결과에서 온다.
어댑터(ollama, vllm, cli)의 `New()` 함수가 `config.XxxConf`를 받으므로 proto payload로부터 어댑터를 생성하는 factory가 필요하다.
#### 해결 방법
**`apps/node/internal/node/node.go` 변경:**
`node.New` 시그니처에서 `*config.NodeConfig` 제거, `nodeID string` 직접 파라미터로 변경:
Before (L31-45):
```go
func New(
cfg *config.NodeConfig,
router runtime.Router,
registry *adapters.Registry,
st *store.Store,
logger *zap.Logger,
) *Node {
return &Node{
nodeID: cfg.Node.ID,
router: router,
registry: registry,
store: st,
logger: logger,
}
}
```
After:
```go
func New(
nodeID string,
router runtime.Router,
registry *adapters.Registry,
st *store.Store,
logger *zap.Logger,
) *Node {
return &Node{
nodeID: nodeID,
router: router,
registry: registry,
store: st,
logger: logger,
}
}
```
`OnCapabilityRequest` 메서드(L112-127) 삭제 — Handler 인터페이스에서 제거되었으므로 node.Node도 구현할 필요 없다.
`sessionSink`와 `structAsMap`은 그대로 유지한다.
**신규 파일 `apps/node/internal/adapters/factory.go` 작성:**
```go
package adapters
import (
"fmt"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/structpb"
"iop/apps/node/internal/adapters/cli"
"iop/apps/node/internal/adapters/mock"
"iop/apps/node/internal/adapters/ollama"
"iop/apps/node/internal/adapters/vllm"
"iop/packages/config"
iop "iop/proto/gen/iop"
)
// BuildFromPayload creates a Registry from a NodeConfigPayload received from edge.
func BuildFromPayload(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Registry, error) {
reg := NewRegistry()
reg.Register(mock.New(logger)) // mock is always available
for _, ac := range payload.GetAdapters() {
if !ac.GetEnabled() {
continue
}
switch ac.GetType() {
case "mock":
// already registered above
case "ollama":
cfg := ollamaConfFromStruct(ac.GetSettings())
reg.Register(ollama.New(cfg, logger))
case "vllm":
cfg := vllmConfFromStruct(ac.GetSettings())
reg.Register(vllm.New(cfg, logger))
case "cli":
cfg := cliConfFromStruct(ac.GetSettings())
reg.Register(cli.New(cfg, logger))
default:
return nil, fmt.Errorf("adapters: unknown adapter type %q", ac.GetType())
}
}
return reg, nil
}
func ollamaConfFromStruct(s *structpb.Struct) config.OllamaConf {
if s == nil {
return config.OllamaConf{}
}
m := s.AsMap()
cfg := config.OllamaConf{Enabled: true}
if v, ok := m["base_url"].(string); ok {
cfg.BaseURL = v
}
return cfg
}
func vllmConfFromStruct(s *structpb.Struct) config.VllmConf {
if s == nil {
return config.VllmConf{}
}
m := s.AsMap()
cfg := config.VllmConf{Enabled: true}
if v, ok := m["endpoint"].(string); ok {
cfg.Endpoint = v
}
return cfg
}
func cliConfFromStruct(s *structpb.Struct) config.CLIConf {
if s == nil {
return config.CLIConf{}
}
m := s.AsMap()
cfg := config.CLIConf{Enabled: true, Profiles: make(map[string]config.CLIProfileConf)}
if profiles, ok := m["profiles"].(map[string]any); ok {
for name, pAny := range profiles {
p, ok := pAny.(map[string]any)
if !ok {
continue
}
prof := config.CLIProfileConf{}
if cmd, ok := p["command"].(string); ok {
prof.Command = cmd
}
if args, ok := p["args"].([]any); ok {
for _, a := range args {
if s, ok := a.(string); ok {
prof.Args = append(prof.Args, s)
}
}
}
if envs, ok := p["env"].([]any); ok {
for _, e := range envs {
if s, ok := e.(string); ok {
prof.Env = append(prof.Env, s)
}
}
}
cfg.Profiles[name] = prof
}
}
return cfg
}
```
**`apps/node/internal/bootstrap/module.go` 전면 재구성:**
fx provider 방식 대신 OnStart 훅 내부에서 순차 초기화:
After (전체 재작성):
```go
package bootstrap
import (
"context"
"fmt"
"go.uber.org/fx"
"go.uber.org/zap"
"iop/apps/node/internal/adapters"
"iop/apps/node/internal/node"
"iop/apps/node/internal/router"
"iop/apps/node/internal/store"
"iop/apps/node/internal/transport"
"iop/packages/config"
"iop/packages/observability"
)
func Module(cfg *config.NodeConfig) fx.Option {
return fx.Options(
fx.Provide(
func() *config.NodeConfig { return cfg },
func(cfg *config.NodeConfig) (*zap.Logger, error) {
return observability.NewLogger(cfg.Logging.Level)
},
),
fx.Invoke(func(lc fx.Lifecycle, cfg *config.NodeConfig, logger *zap.Logger) {
var sess *transport.Session
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
result, err := transport.DialEdge(ctx, cfg.Transport.EdgeAddr, cfg.Transport.Token, logger)
if err != nil {
return fmt.Errorf("bootstrap: dial edge: %w", err)
}
reg, err := adapters.BuildFromPayload(result.Config, logger)
if err != nil {
_ = result.Session.Close()
return fmt.Errorf("bootstrap: build adapters: %w", err)
}
dsn := "file:iop.db?cache=shared&mode=rwc"
if result.Config.GetRuntime().GetWorkspaceRoot() != "" {
dsn = "file:" + result.Config.GetRuntime().GetWorkspaceRoot() + "/iop.db?cache=shared&mode=rwc"
}
st, err := store.New(dsn, logger)
if err != nil {
_ = result.Session.Close()
return fmt.Errorf("bootstrap: store: %w", err)
}
rtr := router.New(reg, logger)
n := node.New(result.NodeID, rtr, reg, st, logger)
result.Session.SetHandler(n)
sess = result.Session
go func() {
if err := observability.ServeMetrics(cfg.Metrics.Port); err != nil {
logger.Warn("metrics server exited", zap.Error(err))
}
}()
return nil
},
OnStop: func(_ context.Context) error {
if sess != nil {
return sess.Close()
}
return nil
},
})
}),
)
}
```
#### 수정 파일 및 체크리스트
- [x] `apps/node/internal/node/node.go` — New() 시그니처에서 *config.NodeConfig 제거 → nodeID string 직접 파라미터, OnCapabilityRequest 메서드 삭제, config import 제거
- [x] `apps/node/internal/adapters/factory.go` — 신규 작성
- [x] `apps/node/internal/bootstrap/module.go` — 전면 재구성 (OnStart 내부 순차 초기화)
- [x] `apps/node/cmd/node/main.go` — config print 커맨드에서 더 이상 존재하지 않는 필드 참조 제거 여부 확인 (NodeConfig 구조체가 단순해졌으므로 코드 자체는 동작하지만, 출력 내용이 달라짐)
#### 테스트 작성
skip — node 단위 테스트는 REFACTOR-7에서 갱신한다.
#### 중간 검증
```bash
go build ./apps/node/...
```
---
### [REFACTOR-7] 테스트 전체 갱신
#### 문제
모든 테스트가 현재 handshake(CapabilityRequest 기반)와 이전 구조(NodeConfig, OnCapabilityRequest)를 기준으로 작성되어 있다.
#### 해결 방법
**`apps/edge/internal/transport/integration_test.go` 재작성:**
- `TestEdgeServerIntegration`: CapabilityRequest/Response 흐름 제거 → mock node가 연결 후 `RegisterRequest{token}`을 보내고 `RegisterResponse` 수신 확인. `waitForRegistryEntry` 조건을 RegisterResponse 수신 이후로 변경.
- mock nodeParser에 `RegisterResponse` 파서 추가, `CapabilityRequest` 파서 제거.
- edge 서버 생성 시 `NodeStore`를 주입 (`LoadFromConfig`로 test token 등록).
**`apps/node/internal/transport/integration_test.go` 재작성:**
- `mockHandler`에서 `OnCapabilityRequest` 제거.
- `TestNodeClientIntegration`: mock edge가 `RegisterRequest`를 받아 `RegisterResponse`로 응답. CapabilityRequest 전송 부분(L116-127) 제거.
- mock edgeParser에 `RegisterRequest` 추가, `CapabilityResponse` 제거.
- `transport.DialEdge` 새 시그니처(`addr, token string`, handler 없음) 반영.
**`apps/node/internal/node/node_test.go` 갱신:**
- `TestOnCapabilityRequest`(L82-107) 삭제 — Handler 인터페이스에서 제거됨.
- `makeNode`(L65-80): `cfg := &config.NodeConfig{...}` → `node.New("test-node", ...)` 직접 호출로 변경.
- `TestOnRunRequest_RouterError`, `TestOnRunRequest_AdapterNotFound`, `TestOnRunRequest_Success`, `TestOnCancel_CallsCancelFn` — node.New 시그니처 변경만 반영, 로직은 그대로 유지.
**`apps/edge/internal/node/registry_test.go` 갱신:**
- `TestRegistry_RegisterAndCount`(L12-22): `NodeEntry`에서 `Adapters` 필드 제거됨 → 초기화 코드에서 `Adapters` 제거.
#### 수정 파일 및 체크리스트
- [x] `apps/edge/internal/transport/integration_test.go` — RegisterRequest/Response 기반으로 재작성
- [x] `apps/node/internal/transport/integration_test.go` — RegisterRequest/Response 기반으로 재작성, DialEdge 시그니처 반영
- [x] `apps/node/internal/node/node_test.go` — TestOnCapabilityRequest 삭제, makeNode 수정, 나머지 테스트 node.New 시그니처 반영
- [x] `apps/edge/internal/node/registry_test.go` — NodeEntry Adapters 제거 반영
#### 테스트 작성
위 갱신 외 신규 테스트:
- `apps/node/internal/adapters/factory_test.go`:
- `TestBuildFromPayload_MockAlwaysPresent`: 빈 payload → mock adapter만 존재
- `TestBuildFromPayload_OllamaEnabled`: ollama enabled 포함 payload → ollama + mock
- `TestBuildFromPayload_UnknownType`: 알 수 없는 type → 오류
#### 중간 검증
```bash
go test ./apps/edge/internal/node/...
go test ./apps/node/internal/node/...
go test ./apps/node/internal/adapters/...
```
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `proto/iop/runtime.proto` | REFACTOR-1 |
| `proto/gen/iop/runtime.pb.go` | REFACTOR-1 (생성) |
| `packages/config/config.go` | REFACTOR-2 |
| `configs/node.yaml` | REFACTOR-2 |
| `configs/edge.yaml` | REFACTOR-2 |
| `apps/edge/internal/node/store.go` (신규) | REFACTOR-3 |
| `apps/edge/internal/node/registry.go` | REFACTOR-3 |
| `apps/edge/internal/transport/server.go` | REFACTOR-4 |
| `apps/edge/internal/bootstrap/module.go` | REFACTOR-4 |
| `apps/node/internal/transport/session.go` | REFACTOR-5 |
| `apps/node/internal/transport/parser.go` | REFACTOR-5 |
| `apps/node/internal/transport/client.go` | REFACTOR-5 |
| `apps/node/internal/node/node.go` | REFACTOR-6 |
| `apps/node/internal/adapters/factory.go` (신규) | REFACTOR-6 |
| `apps/node/internal/bootstrap/module.go` | REFACTOR-6 |
| `apps/edge/internal/transport/integration_test.go` | REFACTOR-7 |
| `apps/node/internal/transport/integration_test.go` | REFACTOR-7 |
| `apps/node/internal/node/node_test.go` | REFACTOR-7 |
| `apps/edge/internal/node/registry_test.go` | REFACTOR-7 |
| `apps/node/internal/adapters/factory_test.go` (신규) | REFACTOR-7 |
| `apps/edge/internal/node/store_test.go` (신규) | REFACTOR-3 |
## 최종 검증
```bash
make proto # proto 재생성 확인
go build ./... # 전체 컴파일
go test ./... # 전체 테스트
```
모든 테스트 PASS, 컴파일 오류 없음이 기대 결과이다.