419 lines
17 KiB
Text
419 lines
17 KiB
Text
<!-- task=edge_multi_point_routing plan=0 tag=API -->
|
|
|
|
# Edge Multi-Point Routing Plan
|
|
|
|
## 이 파일을 읽는 구현 에이전트에게
|
|
|
|
아래 체크리스트를 순서대로 완료하고, 각 항목의 중간 검증과 최종 검증을 실제로 실행하세요. 구현이 끝나면 `agent-task/edge_multi_point_routing/CODE_REVIEW.md`의 모든 섹션을 실제 구현 내용과 명령 출력으로 채우세요. `CODE_REVIEW.md`의 `이 파일을 읽는 리뷰 에이전트에게` 섹션에 있는 아카이브 지시(`*.log`로 이름 변경, `complete.log` 작성)는 구현 에이전트가 수행하면 안 되며, 리뷰 스킬 전용입니다.
|
|
|
|
## 배경
|
|
|
|
현재 edge console은 연결된 node가 하나라는 전제에서만 자연스럽게 동작합니다. 실제 dispatch는 `Registry.Pick()`의 임의 선택에 의존하고, background 이벤트는 어느 node에서 온 것인지 proto 수준에서 식별할 수 없습니다. 멀티포인트 운영으로 가려면 지금 단계에서 스케줄러까지 도입할 필요는 없지만, 최소한 edge가 명시적으로 대상 node를 선택하고 이벤트 출처를 안정적으로 표시할 수 있어야 합니다.
|
|
|
|
## 의존 관계 및 구현 순서
|
|
|
|
1. `API-1`에서 registry에 명시적 node 조회 API를 추가한다.
|
|
2. `API-2`에서 console이 `Pick()` 대신 명시적 target node를 사용하도록 바꾼다.
|
|
3. `API-3`에서 `RunEvent`에 node 출처를 실어 async/foreground 출력이 모두 node-aware 하게 만든다.
|
|
|
|
### [API-1] Registry를 임의 Pick에서 명시적 조회 API 중심으로 전환
|
|
|
|
#### 문제
|
|
|
|
현재 registry는 `node_id` 기반 map 하나만 유지하고, 실제 선택 API는 `apps/edge/internal/node/registry.go:62-68`의 `Pick()`뿐입니다. 이 함수는 map 순회 첫 값을 반환하므로 멀티 node 환경에서 대상 선택이 비결정적이고, alias 기반 선택도 할 수 없습니다. existing test도 `apps/edge/internal/node/registry_test.go:24-28`에서 빈 registry의 `Pick()` 에러만 확인할 뿐, 멀티포인트 선택 규칙은 전혀 고정하지 못합니다.
|
|
|
|
Before (`apps/edge/internal/node/registry.go:17-68`):
|
|
|
|
```go
|
|
// Registry manages all nodes connected to edge.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
nodes map[string]*NodeEntry
|
|
}
|
|
|
|
func NewRegistry() *Registry {
|
|
return &Registry{nodes: make(map[string]*NodeEntry)}
|
|
}
|
|
|
|
func (r *Registry) Register(entry *NodeEntry) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.nodes[entry.NodeID] = entry
|
|
}
|
|
|
|
func (r *Registry) Unregister(nodeID string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
delete(r.nodes, nodeID)
|
|
}
|
|
|
|
func (r *Registry) Get(nodeID string) (*NodeEntry, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
e, ok := r.nodes[nodeID]
|
|
return e, ok
|
|
}
|
|
|
|
func (r *Registry) Pick() (*NodeEntry, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
for _, e := range r.nodes {
|
|
return e, nil
|
|
}
|
|
return nil, fmt.Errorf("no nodes connected")
|
|
}
|
|
```
|
|
|
|
#### 해결 방법
|
|
|
|
registry를 `byID`와 `byAlias` 조회가 가능한 구조로 확장하고, console 쪽에서 사용할 `Resolve(ref string)` 또는 동등한 명시 API를 제공합니다. fallback은 “연결 node가 정확히 1개일 때만 자동 선택”으로 제한하고, 2개 이상이면 caller가 명시적으로 alias/id를 지정해야 에러 없이 진행되도록 합니다. `Pick()`은 제거하거나 deprecated helper로 남기더라도 console 경로에서 더 이상 사용하지 않게 합니다.
|
|
|
|
After:
|
|
|
|
```go
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
byID map[string]*NodeEntry
|
|
byAlias map[string]*NodeEntry
|
|
}
|
|
|
|
func NewRegistry() *Registry {
|
|
return &Registry{
|
|
byID: make(map[string]*NodeEntry),
|
|
byAlias: make(map[string]*NodeEntry),
|
|
}
|
|
}
|
|
|
|
func (r *Registry) Register(entry *NodeEntry) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.byID[entry.NodeID] = entry
|
|
if entry.Alias != "" {
|
|
r.byAlias[entry.Alias] = entry
|
|
}
|
|
}
|
|
|
|
func (r *Registry) Resolve(ref string) (*NodeEntry, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
if ref != "" {
|
|
if entry, ok := r.byID[ref]; ok {
|
|
return entry, nil
|
|
}
|
|
if entry, ok := r.byAlias[ref]; ok {
|
|
return entry, nil
|
|
}
|
|
return nil, fmt.Errorf("node %q not found", ref)
|
|
}
|
|
|
|
if len(r.byID) == 1 {
|
|
for _, entry := range r.byID {
|
|
return entry, nil
|
|
}
|
|
}
|
|
if len(r.byID) == 0 {
|
|
return nil, fmt.Errorf("no nodes connected")
|
|
}
|
|
return nil, fmt.Errorf("multiple nodes connected; select one with /node <id|alias>")
|
|
}
|
|
```
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `apps/edge/internal/node/registry.go` - `byAlias` 저장과 `Resolve`/동등 helper 추가, `Pick()` 의존 경로 제거 또는 deprecated 처리
|
|
- [ ] `apps/edge/internal/node/registry_test.go` - alias 조회, single-node fallback, multi-node ambiguous error, unregister cleanup 테스트 추가
|
|
- [ ] `apps/edge/internal/transport/integration_test.go` - registry에 alias가 채워진 entry가 실제 등록되는 현재 동작이 유지되는지 확인하고 필요한 assertion 보강
|
|
- [ ] renamed/removed symbol call site 점검: `rg -n "\.Pick\(|Resolve\(" apps/edge`로 console 외에 남은 임의 선택 경로가 없는지 확인
|
|
|
|
#### 테스트 작성
|
|
|
|
테스트를 작성합니다.
|
|
|
|
- `apps/edge/internal/node/registry_test.go` - `TestRegistryResolve_ByAliasOrID`: alias와 node_id 모두 같은 entry로 해석되는지 검증
|
|
- `apps/edge/internal/node/registry_test.go` - `TestRegistryResolve_ImplicitSingleNodeOnly`: node가 1개일 때만 빈 ref가 성공하고, 2개 이상이면 명시적 선택 에러가 나는지 검증
|
|
- `apps/edge/internal/node/registry_test.go` - `TestRegistryResolve_UnregisterRemovesAlias`: unregister 후 alias 경로도 함께 제거되는지 검증
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
go test ./apps/edge/internal/node/... ./apps/edge/internal/transport/...
|
|
```
|
|
|
|
기대 결과: registry lookup 규칙과 edge transport integration 테스트가 모두 통과합니다.
|
|
|
|
### [API-2] Console 실행/세션 종료를 target node 명시 모델로 변경
|
|
|
|
#### 문제
|
|
|
|
console state는 `apps/edge/cmd/edge/console.go:52-62`에서 `adapter/agent/session/background`만 들고 있고, 명령 목록에도 node 선택 개념이 없습니다. 실제 실행과 세션 종료는 `apps/edge/cmd/edge/console.go:159-239`에서 모두 `registry.Pick()`에 의존하므로, node가 여러 대 연결된 순간 동일 `agent/session_id` 요청이 어느 node로 갈지 사용자가 제어할 수 없습니다. 현재 테스트도 `apps/edge/cmd/edge/console_test.go:11-54`에서 `buildRunRequest`의 session/background만 검증하고 있어 target selection 회귀를 막지 못합니다.
|
|
|
|
Before (`apps/edge/cmd/edge/console.go:52-62,159-239`):
|
|
|
|
```go
|
|
// Console state: adapter, agent, session, background mode.
|
|
adapter := cfg.Console.Adapter
|
|
agent := cfg.Console.ResolveAgent()
|
|
sessionID := normalizeConsoleSessionID(cfg.Console.SessionID)
|
|
background := cfg.Console.Background
|
|
timeoutSec := cfg.Console.TimeoutSec
|
|
|
|
fmt.Fprintf(out, "IOP Edge console listening on %s\n", cfg.Server.Listen)
|
|
fmt.Fprintf(out, "Console target adapter=%s agent=%s session=%s background=%v\n", adapter, agent, sessionID, background)
|
|
fmt.Fprintln(out, "Start node.sh on another host, then type a message here.")
|
|
fmt.Fprintln(out, "Commands: /nodes, /session <id>, /background on|off, /terminate-session, /exit")
|
|
|
|
func sendConsoleRun(ctx context.Context, registry *edgenode.Registry, events *consoleEventRouter, out io.Writer, adapter, agent, sessionID string, background bool, timeoutSec int, message string) error {
|
|
entry, err := registry.Pick()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
...
|
|
}
|
|
|
|
func sendTerminateSession(ctx context.Context, registry *edgenode.Registry, adapter, model, sessionID string) error {
|
|
entry, err := registry.Pick()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
...
|
|
}
|
|
```
|
|
|
|
#### 해결 방법
|
|
|
|
console state에 `targetNodeRef`를 추가하고 `/node <id|alias>` 명령을 도입합니다. dispatch helper는 `registry.Resolve(targetNodeRef)`를 호출하고, 빈 target은 single-node일 때만 암묵 선택되게 합니다. `/nodes` 출력은 현재 선택된 node를 함께 보여주고, startup banner와 `/terminate-session` 결과도 `node=<alias>`를 포함하게 바꿔 사용자가 현재 작업 컨텍스트를 잃지 않게 합니다.
|
|
|
|
After:
|
|
|
|
```go
|
|
type consoleTarget struct {
|
|
NodeRef string
|
|
Adapter string
|
|
Agent string
|
|
SessionID string
|
|
Background bool
|
|
TimeoutSec int
|
|
}
|
|
|
|
func resolveConsoleNode(registry *edgenode.Registry, nodeRef string) (*edgenode.NodeEntry, error) {
|
|
return registry.Resolve(nodeRef)
|
|
}
|
|
|
|
// Commands: /nodes, /node <id|alias>, /session <id>, /background on|off, /terminate-session, /exit
|
|
|
|
case strings.HasPrefix(lower, "/node "):
|
|
parts := strings.Fields(message)
|
|
if len(parts) < 2 || parts[1] == "" {
|
|
fmt.Fprintln(out, "usage: /node <id|alias>")
|
|
continue
|
|
}
|
|
if _, err := resolveConsoleNode(registry, parts[1]); err != nil {
|
|
fmt.Fprintf(out, "error: %v\n", err)
|
|
continue
|
|
}
|
|
target.NodeRef = parts[1]
|
|
fmt.Fprintf(out, "node → %s\n", target.NodeRef)
|
|
```
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `apps/edge/cmd/edge/console.go` - console state에 `target node` 축 추가, `/node <id|alias>` 명령 추가, startup/help 문구 갱신
|
|
- [ ] `apps/edge/cmd/edge/console.go` - `sendConsoleRun`/`sendTerminateSession`가 `registry.Resolve(...)`를 사용하도록 변경
|
|
- [ ] `apps/edge/cmd/edge/console.go` - `/nodes` 출력이 현재 선택 상태를 확인하기 쉽게 보강
|
|
- [ ] `apps/edge/cmd/edge/console_test.go` - target node helper와 `/node` command 회귀 테스트 추가
|
|
- [ ] renamed/removed symbol call site 점검: `rg -n "sendConsoleRun|sendTerminateSession|/nodes|/node " apps/edge/cmd/edge`
|
|
|
|
#### 테스트 작성
|
|
|
|
테스트를 작성합니다.
|
|
|
|
- `apps/edge/cmd/edge/console_test.go` - `TestResolveConsoleNode_RequiresExplicitSelectionForMultipleNodes`: 빈 target으로 multi-node registry를 조회하면 에러가 나는지 검증
|
|
- `apps/edge/cmd/edge/console_test.go` - `TestResolveConsoleNode_AllowsSingleNodeFallback`: node 하나일 때만 빈 target이 허용되는지 검증
|
|
- `apps/edge/cmd/edge/console_test.go` - `TestPrintNodes_ShowsSelectedNode`: `/nodes` 출력에 selected marker 또는 동등한 상태 표시가 포함되는지 검증
|
|
- `apps/edge/cmd/edge/console_test.go` - `TestSendTerminateSession_UsesResolvedNode`: 선택된 alias/id가 올바른 node entry로 해석되는 helper 경로를 검증
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
go test ./apps/edge/cmd/edge/... ./apps/edge/internal/node/...
|
|
```
|
|
|
|
기대 결과: console helper와 node selection 관련 테스트가 통과하고, multi-node ambiguous 상황이 의도한 에러로 고정됩니다.
|
|
|
|
### [API-3] RunEvent에 node 출처를 추가해 foreground/background 출력 모두 node-aware 하게 만들기
|
|
|
|
#### 문제
|
|
|
|
현재 `proto/iop/runtime.proto:36-48`의 `RunEvent`에는 `session_id`와 `background`만 있고 `node_id`가 없습니다. node 쪽 `apps/node/internal/node/node.go:175-203`의 `sessionSink.Emit`도 출처 node 정보를 채우지 않고, edge 쪽 `apps/edge/cmd/edge/console_events.go:66-97`는 async event를 `[node-event]`, `[node-message]`로만 출력합니다. 그래서 background 실행이나 추후 비동기 fan-in 상황에서 어떤 node가 어떤 이벤트를 보냈는지 proto와 출력 양쪽 모두에서 잃어버립니다.
|
|
|
|
Before (`proto/iop/runtime.proto:36-48`):
|
|
|
|
```proto
|
|
// RunEvent is a streaming execution event.
|
|
message RunEvent {
|
|
string run_id = 1;
|
|
string type = 2; // start | delta | complete | error | cancelled
|
|
string delta = 3;
|
|
string message = 4;
|
|
string error = 5;
|
|
Usage usage = 6;
|
|
map<string, string> metadata = 7;
|
|
int64 timestamp = 8; // unix nano
|
|
string session_id = 9;
|
|
bool background = 10;
|
|
}
|
|
```
|
|
|
|
Before (`apps/node/internal/node/node.go:175-203`):
|
|
|
|
```go
|
|
type sessionSink struct {
|
|
sess *transport.Session
|
|
out io.Writer
|
|
sessionID string
|
|
background bool
|
|
response strings.Builder
|
|
}
|
|
|
|
func (s *sessionSink) Emit(_ context.Context, event runtime.RuntimeEvent) error {
|
|
s.printEvent(event)
|
|
re := &iop.RunEvent{
|
|
RunId: event.RunID,
|
|
Type: string(event.Type),
|
|
Delta: event.Delta,
|
|
Message: event.Message,
|
|
Error: event.Error,
|
|
Metadata: event.Metadata,
|
|
Timestamp: event.Timestamp.UnixNano(),
|
|
SessionId: s.sessionID,
|
|
Background: s.background,
|
|
}
|
|
...
|
|
}
|
|
```
|
|
|
|
Before (`apps/edge/cmd/edge/console_events.go:66-97`):
|
|
|
|
```go
|
|
func (r *consoleEventRouter) printAsyncLocked(event *iop.RunEvent) {
|
|
...
|
|
switch event.GetType() {
|
|
case "start":
|
|
fmt.Fprintf(r.out, "[node-event] start run_id=%s session=%s background=%v\n", runID, event.GetSessionId(), event.GetBackground())
|
|
case "delta":
|
|
r.responseStream(runID).Write(event.GetDelta())
|
|
case "complete":
|
|
r.finishStream(runID)
|
|
fmt.Fprintf(r.out, "[node-event] complete run_id=%s detail=%q\n", runID, event.GetMessage())
|
|
...
|
|
}
|
|
```
|
|
|
|
#### 해결 방법
|
|
|
|
`RunEvent`에 `node_id`를 추가하고, `sessionSink`가 `Node.nodeID`를 채워 보내게 합니다. edge console event router는 registry를 참조해 `node_id -> alias`를 해석하고, foreground와 async 출력 모두 `[node-{alias}-event]`, `[node-{alias}-message]` 형태를 동일하게 사용합니다. registry에서 alias를 찾지 못하더라도 fallback으로 `node_id`를 그대로 보여주게 해 event provenance를 잃지 않게 합니다.
|
|
|
|
After:
|
|
|
|
```proto
|
|
message RunEvent {
|
|
string run_id = 1;
|
|
string type = 2;
|
|
string delta = 3;
|
|
string message = 4;
|
|
string error = 5;
|
|
Usage usage = 6;
|
|
map<string, string> metadata = 7;
|
|
int64 timestamp = 8;
|
|
string session_id = 9;
|
|
bool background = 10;
|
|
string node_id = 11;
|
|
}
|
|
```
|
|
|
|
```go
|
|
type sessionSink struct {
|
|
sess *transport.Session
|
|
out io.Writer
|
|
nodeID string
|
|
sessionID string
|
|
background bool
|
|
response strings.Builder
|
|
}
|
|
|
|
re := &iop.RunEvent{
|
|
RunId: event.RunID,
|
|
Type: string(event.Type),
|
|
...
|
|
SessionId: s.sessionID,
|
|
Background: s.background,
|
|
NodeId: s.nodeID,
|
|
}
|
|
```
|
|
|
|
```go
|
|
func (r *consoleEventRouter) nodeLabel(event *iop.RunEvent) string {
|
|
if event.GetNodeId() == "" {
|
|
return "unknown"
|
|
}
|
|
if entry, ok := r.registry.Get(event.GetNodeId()); ok && entry.Alias != "" {
|
|
return entry.Alias
|
|
}
|
|
return event.GetNodeId()
|
|
}
|
|
```
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `proto/iop/runtime.proto` - `RunEvent.node_id` 추가
|
|
- [ ] `apps/node/internal/node/node.go` - `sessionSink`가 `nodeID`를 보관하고 `RunEvent.NodeId`를 채우도록 변경
|
|
- [ ] `apps/edge/cmd/edge/console_events.go` - registry 주입, node label helper, async 출력 prefix를 node-aware 하게 변경
|
|
- [ ] `apps/edge/cmd/edge/console.go` - `newConsoleEventRouter(...)` 생성 시 registry 전달, foreground 출력도 event의 node label과 일관된 prefix 사용
|
|
- [ ] `apps/edge/cmd/edge/console_test.go` - async event 출력에 node label이 포함되는지 검증
|
|
- [ ] `apps/edge/README.md` - `/node` 명령, multi-node 명시 선택 규칙, node-aware event 출력 예시 반영
|
|
- [ ] `proto/gen/iop/*.pb.go` - `make proto`로 regenerated output 반영
|
|
- [ ] renamed/removed symbol call site 점검: `rg -n "NodeId|node_id|newConsoleEventRouter|RunEvent{" apps proto`
|
|
|
|
#### 테스트 작성
|
|
|
|
테스트를 작성합니다.
|
|
|
|
- `apps/edge/cmd/edge/console_test.go` - `TestConsoleEventRouterPrintsNodeScopedAsyncRun`: `RunEvent{NodeId: "node-local", ...}`를 넣었을 때 `[node-local-node-message]` 또는 최종 선택한 포맷에 맞는 node-scoped 출력이 나오는지 검증
|
|
- `apps/edge/cmd/edge/console_test.go` - `TestConsoleEventRouterFallsBackToNodeID`: alias를 해석하지 못해도 `node_id` 자체가 출력에 포함되는지 검증
|
|
- `apps/node/internal/node/node_test.go` - `TestSessionSinkEmitIncludesNodeID`: emitted `RunEvent`에 `NodeId`가 채워지는지 검증
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
make proto
|
|
go test ./apps/node/internal/node/... ./apps/edge/cmd/edge/...
|
|
```
|
|
|
|
기대 결과: protobuf 생성이 성공하고, node event emission 및 edge console event formatting 테스트가 모두 통과합니다. 실행 환경에 `make`가 없으면 Makefile의 `proto` 타깃과 동일한 `protoc` 명령으로 대체하고 그 사실을 `CODE_REVIEW.md`에 기록합니다.
|
|
|
|
## 수정 파일 요약
|
|
|
|
| 파일 | 항목 |
|
|
|------|------|
|
|
| `apps/edge/internal/node/registry.go` | API-1 |
|
|
| `apps/edge/internal/node/registry_test.go` | API-1 |
|
|
| `apps/edge/internal/transport/integration_test.go` | API-1 |
|
|
| `apps/edge/cmd/edge/console.go` | API-2, API-3 |
|
|
| `apps/edge/cmd/edge/console_events.go` | API-3 |
|
|
| `apps/edge/cmd/edge/console_test.go` | API-2, API-3 |
|
|
| `proto/iop/runtime.proto` | API-3 |
|
|
| `proto/gen/iop/*.pb.go` | API-3 |
|
|
| `apps/node/internal/node/node.go` | API-3 |
|
|
| `apps/node/internal/node/node_test.go` | API-3 |
|
|
| `apps/edge/README.md` | API-3 |
|
|
|
|
## 최종 검증
|
|
|
|
```bash
|
|
make proto
|
|
go test ./apps/edge/... ./apps/node/internal/node/...
|
|
go test ./...
|
|
```
|
|
|
|
기대 결과: proto 재생성과 edge/node 관련 테스트가 모두 통과하고, 전체 저장소 테스트도 회귀 없이 PASS 합니다. `make`가 없으면 동등 `protoc` 명령으로 대체한 뒤 `go test ./...` 결과를 기록합니다.
|