<!-- 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 ./...` 결과를 기록합니다.
