<!-- task=m-control-plane-edge-wire-baseline/04_status_snapshot plan=0 tag=STATUS -->

# Control Plane-Edge Status Snapshot Plan

## 이 파일을 읽는 구현 에이전트에게

`CODE_REVIEW-cloud-G08.md`의 구현 에이전트 소유 섹션을 채우는 것까지가 구현입니다. 구현 후 검증을 실행하고 실제 변경 내용, 검증 출력, 계획 대비 변경 사항을 review stub에 기록한 뒤 active 파일을 그대로 둔 채 리뷰 준비를 보고하세요. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review 스킬 책임입니다.

구현 중 사용자만 결정할 수 있는 범위 변경, 사용자 소유 외부 환경/secret, 또는 현재 계획과 충돌하는 요구가 발견되면 `CODE_REVIEW-cloud-G08.md`의 `사용자 리뷰 요청` 섹션에 정확한 결정 항목과 근거를 기록하고 중단하세요. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해결할 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니라 일반 후속 작업입니다.

## 배경

현 마일스톤의 3번째 Epic에는 Control Plane이 Edge-owned 상태 snapshot을 요청하고 응답받는 기능이 남아 있습니다. 현재 Control Plane-Edge wire는 hello/enrollment와 registry 조회까지는 갖췄지만, Edge 쪽 node registry 상태를 Control Plane이 wire request/response로 읽는 계약은 없습니다. 이 작업은 status snapshot의 최소 proto 계약과 양방향 handler를 추가하되, Control Plane이 Node를 직접 보거나 스케줄링하지 않는 경계를 유지합니다.

## 사용자 리뷰 요청 흐름

구현 중 차단 사유가 생기면 active `CODE_REVIEW-cloud-G08.md`의 `사용자 리뷰 요청` 섹션을 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 채웁니다. code-review 스킬이 그 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정합니다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md`
- Task ids:
  - `edge-status`: Control Plane은 Edge에 상태 snapshot을 요청하고 Edge-owned 상태를 응답받을 수 있다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-roadmap/current.md`
- `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
- `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/project/domain/control-plane/rules.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/update-test/SKILL.md`
- `agent-ops/skills/common/update-roadmap/SKILL.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/skills/project/e2e-smoke/SKILL.md`
- `agent-test/local/rules.md`
- `agent-test/local/control-plane-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-test/local/testing-smoke.md`
- `proto/iop/control.proto`
- `proto/iop/runtime.proto`
- `proto/iop/node.proto`
- `apps/control-plane/internal/wire/edge.go`
- `apps/control-plane/internal/wire/wire.go`
- `apps/control-plane/internal/wire/edge_registry.go`
- `apps/control-plane/internal/wire/edge_server.go`
- `apps/control-plane/internal/wire/edge_test.go`
- `apps/control-plane/internal/wire/edge_server_test.go`
- `apps/control-plane/internal/wire/client.go`
- `apps/control-plane/internal/wire/client_test.go`
- `apps/control-plane/cmd/control-plane/main.go`
- `apps/control-plane/cmd/control-plane/main_test.go`
- `apps/edge/internal/controlplane/connector.go`
- `apps/edge/internal/controlplane/connector_test.go`
- `apps/edge/internal/controlplane/heartbeat_test.go`
- `apps/edge/internal/controlplane/helpers_test.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/events/bus.go`
- `apps/edge/internal/events/bus_test.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `apps/edge/internal/node/registry.go`
- `apps/edge/internal/node/registry_test.go`
- `scripts/e2e-control-plane-edge-wire.sh`
- `Makefile`
- `go.mod`

### 테스트 환경 규칙

- `test_env=local`로 판단했고 `agent-test/local/rules.md`를 읽었습니다.
- 매칭 profile은 `control-plane-smoke`, `edge-smoke`, `platform-common-smoke`, `testing-smoke`입니다.
- 적용 명령은 `make proto`, `go test ./apps/control-plane/...`, `go test ./apps/edge/...`, `go test ./packages/... ./proto/gen/...`, `go test ./...`, `make test-control-plane-edge-wire`입니다.
- `control-plane-smoke`는 이번 작업 직전 `make test-control-plane-edge-wire` 기준을 명시하도록 갱신되었고 다시 읽었습니다.
- profile에 `<확인 필요>` 값은 없었습니다. Docker compose health/manual full-cycle 항목은 실제 환경이 필요하므로 이 계획의 필수 자동 검증은 Go 테스트와 local wire smoke에 둡니다.

### 테스트 커버리지 공백

- Status proto roundtrip: 현재 `EdgeHelloRequest`/`EdgeHelloResponse`만 parser roundtrip 테스트가 있고 status request/response 테스트가 없습니다.
- Control Plane status 요청: 현재 `EdgeServer`는 hello를 받고 registry에 연결 상태만 저장하며, 연결된 Edge에 typed request를 보내는 테스트가 없습니다.
- Edge status 응답: 현재 `Connector`는 hello만 보내고 Control Plane request listener가 없으며, `service.ListNodeSnapshots()`로 응답하는 테스트가 없습니다.
- Boundary 검증: 현재 service DTO (`NodeSnapshot`) 테스트는 있지만 Control Plane connector가 이 표면을 사용한다는 테스트가 없습니다.

### 심볼 참조

- renamed/removed symbol: none. 새 proto message와 새 handler/API를 추가하는 작업입니다.

### 분할 판단

- split decision policy를 plan 파일 선택 전에 평가했습니다.
- 공유 task group은 `m-control-plane-edge-wire-baseline`입니다.
- `04_status_snapshot`: proto/status request-response foundation입니다. 다른 작업이 이 wire/parser/connector foundation에 의존합니다.
- `05+04_event_relay_boundary`: node lifecycle event relay와 service/events boundary 검증입니다. `04_status_snapshot`의 parser/connector 구조가 완료된 뒤 진행합니다.
- protocol/schema, Control Plane wire, Edge connector, service boundary가 동시에 걸리므로 단일 plan으로 묶지 않습니다.

### 범위 결정 근거

- Control Plane이 Node에 직접 연결하거나 직접 스케줄링하는 계약은 만들지 않습니다.
- Edge 내부 registry map을 Control Plane에 복제하지 않고, Edge가 응답하는 snapshot DTO만 Control Plane이 관찰합니다.
- Flutter client, durable DB 저장, 권한/정책/감사, run dispatch/command 실행 제어는 제외합니다.
- `scripts/e2e-control-plane-edge-wire.sh`는 이번 status request/response의 필수 구현 파일이 아니며, 기존 local smoke는 회귀 검증으로만 사용합니다.

### 빌드 등급

- build lane: `cloud-G08`, review lane: `cloud-G08`.
- 근거: protobuf 계약과 양쪽 앱의 proto-socket request/response handler를 바꾸는 cross-domain protocol 작업이며, 테스트는 가능하지만 API/경계 회귀 위험이 있습니다.

## 구현 체크리스트

- [x] `proto/iop/control.proto`에 Control Plane-Edge status request/response와 node snapshot message를 추가하고 `make proto`로 Go 생성물을 갱신한다.
- [x] Control Plane `wire.EdgeServer`가 connected Edge client에 status request를 보내고 response를 받을 수 있도록 client tracking과 parser/test를 추가한다.
- [x] Edge `controlplane.Connector`가 Control Plane status request를 수신하면 `apps/edge/internal/service`의 node snapshot 표면으로 응답하도록 option과 bootstrap wiring을 추가한다.
- [x] Status proto roundtrip, Control Plane request/response, Edge service-backed response, bootstrap wiring 테스트를 추가하고 대상 Go 테스트를 통과시킨다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## STATUS-1 Proto Status Contract

### 문제

`proto/iop/control.proto:57`-`63`에는 Edge enrollment 응답까지만 있고, Control Plane이 Edge-owned 상태 snapshot을 요청하는 message가 없습니다. 기존 `proto/iop/node.proto:7`-`17`의 `NodeInfo`는 legacy placeholder라서 새 Control Plane-Edge status 계약에 그대로 쓰면 Node 직접 관찰처럼 보일 수 있습니다.

현재 코드:

```proto
// proto/iop/control.proto:57
message EdgeHelloResponse {
  bool accepted = 1;
  string protocol = 2;
  int64 server_time_unix_nano = 3;
  string message = 4;
  string reason = 5;
}
```

### 해결 방법

`control.proto`에 Control Plane-Edge 전용 status message를 추가합니다. node snapshot은 Edge service가 제공하는 surface-neutral DTO만 담고, address/token/scheduling 필드는 넣지 않습니다.

예상 형태:

```proto
message EdgeStatusRequest {
  string request_id = 1;
}

message EdgeNodeSnapshot {
  string node_id = 1;
  string alias = 2;
  string label = 3;
  bool connected = 4;
}

message EdgeStatusResponse {
  string request_id = 1;
  string edge_id = 2;
  string edge_name = 3;
  int64 observed_time_unix_nano = 4;
  repeated EdgeNodeSnapshot nodes = 5;
  map<string, string> metadata = 6;
  string error = 7;
}
```

### 수정 파일 및 체크리스트

- [x] `proto/iop/control.proto`: 새 message 추가, field number 안정화.
- [x] `proto/gen/iop/control.pb.go`: `make proto`로만 갱신.
- [x] `apps/control-plane/internal/wire/edge.go`: status request/response parser 추가.
- [x] `apps/edge/internal/controlplane/connector.go`: Edge 쪽 parser에 status request/response 추가.

### 테스트 작성

- `apps/control-plane/internal/wire/edge_test.go`에 `TestEdgeParserMapStatusRoundTrip`을 추가합니다.
- assertion: `EdgeStatusRequest`, `EdgeStatusResponse`가 `EdgeParserMap()`에서 marshal/parse roundtrip되고, forbidden direct scheduling field가 status request에도 없음을 확인합니다.

### 중간 검증

```bash
make proto
go test -count=1 ./apps/control-plane/internal/wire ./proto/gen/...
```

기대 결과: proto 생성이 성공하고 status parser/contract 테스트가 통과합니다.

## STATUS-2 Control Plane Status Request Path

### 문제

`apps/control-plane/internal/wire/edge_server.go:62`-`91`은 hello request listener만 등록하고, 연결된 Edge client를 status 요청에 사용할 수 있게 보관하지 않습니다. `apps/control-plane/internal/wire/edge_registry.go:12`-`24`도 외부 상태 snapshot만 보관하고 active client 접근 표면이 없습니다.

현재 코드:

```go
// apps/control-plane/internal/wire/edge_server.go:62
proto_socket.AddRequestListenerTyped(&client.Communicator, func(req *iop.EdgeHelloRequest) (*iop.EdgeHelloResponse, error) {
    now := time.Now()
    if req.GetEdgeId() == "" {
        // ...
    }
    connToken := registry.MarkConnected(req, now)
    // ...
    return &iop.EdgeHelloResponse{Accepted: true, Protocol: Protocol}, nil
})
```

### 해결 방법

`EdgeServer`가 hello acceptance 이후 active `TcpClient`를 edge id/token 기준으로 추적하고, `RequestStatus(ctx, edgeID string, timeout time.Duration) (*iop.EdgeStatusResponse, error)`를 제공합니다. disconnect가 current token일 때만 registry와 active client map을 함께 정리합니다.

예상 형태:

```go
func (s *EdgeServer) RequestStatus(edgeID string, timeout time.Duration) (*iop.EdgeStatusResponse, error) {
    client, ok := s.activeClient(edgeID)
    if !ok {
        return nil, fmt.Errorf("edge %q is not connected", edgeID)
    }
    return proto_socket.SendRequestTyped[*iop.EdgeStatusRequest, *iop.EdgeStatusResponse](
        &client.Communicator,
        &iop.EdgeStatusRequest{RequestId: fmt.Sprintf("edge-status-%d", time.Now().UnixNano())},
        timeout,
    )
}
```

### 수정 파일 및 체크리스트

- [x] `apps/control-plane/internal/wire/edge_server.go`: active client map, status request method, stale disconnect cleanup 추가.
- [x] `apps/control-plane/internal/wire/edge_registry.go`: disconnect 갱신 성공 여부를 반환하거나 EdgeServer가 stale cleanup을 판정할 수 있는 helper 추가.
- [x] `apps/control-plane/internal/wire/edge_server_test.go`: connected Edge에 status request를 보내는 통합 테스트 추가.

### 테스트 작성

- `TestEdgeServerRequestsStatusFromConnectedEdge`를 추가합니다.
- test Edge client에 `EdgeStatusRequest` listener를 등록하고 hello 후 `server.RequestStatus("edge-dgx-group", 2*time.Second)`가 node snapshot response를 받는지 확인합니다.
- stale disconnect 기존 테스트가 active client cleanup 회귀를 막도록 유지합니다.

### 중간 검증

```bash
go test -count=1 ./apps/control-plane/internal/wire
```

기대 결과: hello/disconnect 기존 테스트와 status request 통합 테스트가 모두 통과합니다.

## STATUS-3 Edge Service-Backed Status Response

### 문제

`apps/edge/internal/controlplane/connector.go:186`-`193`은 Edge hello만 전송하고, `cpParserMap()`은 `EdgeHelloResponse`만 파싱합니다. `apps/edge/internal/bootstrap/runtime.go:64`-`68`도 connector를 만들 때 `Service`를 전달하지 않아 Control Plane request를 Edge-owned status 표면으로 응답할 방법이 없습니다.

현재 코드:

```go
// apps/edge/internal/controlplane/connector.go:186
resp, err := toki.SendRequestTyped[*iop.EdgeHelloRequest, *iop.EdgeHelloResponse](
    &cl.Communicator,
    &iop.EdgeHelloRequest{
        EdgeId:       c.edgeInfo.ID,
        EdgeName:     c.edgeInfo.Name,
        Version:      c.version,
        Capabilities: []string{"run", "node-registry"},
    },
    helloTimeout,
)
```

### 해결 방법

Connector에 optional dependency를 추가하되 기존 tests/call sites가 크게 깨지지 않도록 functional option을 사용합니다. Bootstrap에서는 `WithStatusProvider(svc)`를 전달하고, status listener는 `Service.ListNodeSnapshots()` 결과만 proto response로 변환합니다.

예상 형태:

```go
type StatusProvider interface {
    ListNodeSnapshots() []edgeservice.NodeSnapshot
}

func WithStatusProvider(provider StatusProvider) Option {
    return func(c *Connector) { c.statusProvider = provider }
}
```

### 수정 파일 및 체크리스트

- [x] `apps/edge/internal/controlplane/connector.go`: option, status provider, parser, request listener 추가.
- [x] `apps/edge/internal/bootstrap/runtime.go`: `NewConnector(..., WithStatusProvider(svc))` wiring.
- [x] `apps/edge/internal/controlplane/connector_test.go`: fake Control Plane이 status request를 보내고 service-backed node snapshot을 받는 테스트 추가.
- [x] `apps/edge/internal/bootstrap/runtime_test.go`: runtime이 status provider를 연결한다는 관찰 가능한 테스트 추가.

### 테스트 작성

- `TestConnectorRespondsToStatusRequestFromService`를 추가합니다.
- `edgenode.Registry`에 fake node를 등록한 `edgeservice.Service`를 status provider로 전달하고, fake CP server가 hello 후 `EdgeStatusRequest`를 보내 `EdgeStatusResponse.Nodes`를 확인합니다.
- provider가 없는 경우 error response 또는 empty response가 명확한지 테스트합니다.

### 중간 검증

```bash
go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/service
```

기대 결과: Edge connector, bootstrap wiring, service DTO tests가 통과합니다.

## STATUS-4 Focused Regression And Smoke

### 문제

이 작업은 protobuf 계약, Control Plane wire server, Edge connector, bootstrap wiring을 함께 건드립니다. `agent-test/local/control-plane-smoke.md:52`-`55`는 Control Plane-Edge TCP wire 변경 시 `make test-control-plane-edge-wire` 실행을 요구합니다.

### 해결 방법

대상 package test를 먼저 통과시킨 뒤 전체 Go 회귀와 local wire smoke를 실행합니다. `make test-control-plane-edge-wire`는 status request 자체를 검증하지는 않지만, hello/connect/disconnect lifecycle이 깨지지 않았는지 확인합니다.

### 수정 파일 및 체크리스트

- [x] `Makefile`/`scripts/e2e-control-plane-edge-wire.sh`는 status 구현을 위해 수정하지 않습니다.
- [x] package focused tests를 실행합니다.
- [x] 전체 Go test와 local wire smoke를 실행합니다.

### 테스트 작성

- 새 테스트는 STATUS-1~3 항목의 Go 테스트에 둡니다.
- smoke script는 이번 작업에서 수정하지 않습니다.

### 중간 검증

```bash
go test -count=1 ./apps/control-plane/... ./apps/edge/... ./packages/... ./proto/gen/...
make test-control-plane-edge-wire
```

기대 결과: package test와 existing Control Plane-Edge smoke가 통과합니다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `proto/iop/control.proto` | STATUS-1 |
| `proto/gen/iop/control.pb.go` | STATUS-1 |
| `apps/control-plane/internal/wire/edge.go` | STATUS-1 |
| `apps/control-plane/internal/wire/edge_test.go` | STATUS-1 |
| `apps/control-plane/internal/wire/edge_server.go` | STATUS-2 |
| `apps/control-plane/internal/wire/edge_registry.go` | STATUS-2 |
| `apps/control-plane/internal/wire/edge_server_test.go` | STATUS-2 |
| `apps/edge/internal/controlplane/connector.go` | STATUS-3 |
| `apps/edge/internal/controlplane/connector_test.go` | STATUS-3 |
| `apps/edge/internal/bootstrap/runtime.go` | STATUS-3 |
| `apps/edge/internal/bootstrap/runtime_test.go` | STATUS-3 |

## 의존 관계 및 구현 순서

이 subtask는 runtime dependency가 없는 `04_status_snapshot`입니다. 먼저 proto 계약과 parser를 추가한 뒤 Control Plane request path, Edge response path, regression/smoke 순서로 진행합니다.

## 최종 검증

```bash
make proto
go test -count=1 ./apps/control-plane/internal/wire
go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/service
go test -count=1 ./apps/control-plane/... ./apps/edge/... ./packages/... ./proto/gen/...
go test ./...
make test-control-plane-edge-wire
```

기대 결과: 모든 명령이 성공하고, `make test-control-plane-edge-wire` 출력에 `CP: hello accepted`, `Edge: connected to control plane`, `CP: edge disconnected`, `Control Plane-Edge wire smoke PASSED.`가 포함됩니다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
