<!-- task=m-oto-registration-online-smoke/01_iop_edge_lifecycle plan=0 tag=API -->

# OTO Edge Lifecycle State Foundation

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

`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것까지가 구현입니다. 코드를 바꾼 뒤 검증을 실행하고, 실제 변경 내용과 stdout/stderr를 리뷰 stub에 기록한 상태로 멈추세요. 종결 판정, log rename, `complete.log`, archive 이동은 code-review 스킬 전용입니다. 구현 중 사용자 결정, 사용자 소유 외부 환경, 범위 충돌이 없으면 `사용자 리뷰 요청`을 만들지 않습니다.

## 배경

현재 iop Edge registry는 등록 직후 `LifecycleConnected`를 기본값으로 넣는다. OTO Milestone은 registration accepted와 최초 heartbeat 도착을 분리해 accepted/online/failed 상태와 실패 reason을 확인해야 한다. 이 subtask는 iop Edge 내부 상태와 event metadata를 먼저 정리해 후속 OTO smoke가 믿을 수 있는 기준을 만든다.

## 사용자 리뷰 요청 흐름

구현 중 사용자-only blocker가 있으면 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션을 실제 내용으로 교체하고 멈춘다. code-review가 요청 정당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/oto-registration-online-smoke.md`
- Task ids:
  - `accepted-state`: OTO agent registration accepted 상태가 generic node와 구분되어 기록된다.
  - `failure-reason`: unknown token, duplicate connection, heartbeat timeout이 서로 다른 실패 reason으로 확인된다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-roadmap/current.md`
- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/oto-registration-online-smoke.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-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `apps/edge/internal/transport/server.go`
- `apps/edge/internal/transport/server_test.go`
- `apps/edge/internal/transport/integration_test.go`
- `apps/edge/internal/transport/heartbeat_test.go`
- `apps/edge/internal/node/registry.go`
- `apps/edge/internal/node/registry_test.go`
- `apps/edge/internal/node/store.go`
- `apps/edge/internal/node/store_test.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/events/bus.go`
- `packages/go/events/events.go`
- `../proto-socket/go/base_client.go`
- `../proto-socket/go/tcp_client.go`
- `../proto-socket/go/communicator.go`

### 테스트 환경 규칙

- test_env: `local`.
- iop `agent-test/local/rules.md` 존재 및 읽음.
- 적용 profile: `agent-test/local/edge-smoke.md`, `agent-test/local/platform-common-smoke.md`.
- 적용 명령: `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events`.
- command preflight 결과: 위 명령은 현재 기준 통과했고 `packages/go/events`는 `[no test files]`다.

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

- accepted 상태: `TestEdgeServerOTORegistrationKind`는 OTO kind와 `LifecycleConnected`만 확인한다. accepted와 online 분리는 미커버.
- first heartbeat online: iop transport에는 heartbeat listener가 없다. `proto_socket` 내부 `packets.HeartBeat` listener를 추가로 붙일 수 있음은 확인했지만 Edge 상태 전환 테스트는 없다.
- failure reason: duplicate reason 테스트는 있으나 unknown token event/metadata는 없고, heartbeat timeout은 disconnect metadata만 존재한다.
- service snapshot: `ListNodeSnapshots`는 lifecycle을 전달하지만 accepted/online/failed 값 전환은 미커버.

### 심볼 참조

- 변경/추가 후보: `LifecycleConnected` 참조는 `apps/edge/internal/node/registry.go`, `apps/edge/internal/node/registry_test.go`, `apps/edge/internal/service/service_test.go`, `apps/edge/internal/transport/server_test.go`에 있다.
- 제거하지 말 것: `LifecycleConnected`는 generic node 호환을 위해 유지한다.

### 분할 판단

- split 정책 평가 완료.
- shared task group: `agent-task/m-oto-registration-online-smoke/`.
- `01_iop_edge_lifecycle`: iop Edge lifecycle/event foundation. 의존 없음.
- `02+01_oto_agent_session`: OTO Dart agent session은 Edge lifecycle 기준이 필요하므로 `01`에 의존.
- `03+01,02_smoke_evidence`: 실제 smoke/evidence는 Edge와 OTO 양쪽 구현이 필요하므로 `01`, `02`에 의존.

### 범위 결정 근거

- proto 파일은 변경하지 않는다. `EdgeNodeEvent.type`, `reason`, `metadata` 문자열로 충분하다.
- `../proto-socket/go`는 변경하지 않는다. HeartBeat listener 추가는 소비자인 Edge transport에서 처리한다.
- Control Plane/Client UI 반영은 Milestone 범위 제외다.
- OTO Dart agent 유지 연결은 다음 subtask에서 다룬다.

### 빌드 등급

- build: `cloud-G07`, review: `cloud-G07`. proto-socket heartbeat와 Edge registry lifecycle이 결합된 protocol-adjacent 상태 전환이라 신중한 리뷰가 필요하다.

## 구현 체크리스트

- [ ] OTO agent 등록 직후 lifecycle을 accepted로 기록하고 generic node 기본 connected 동작은 유지한다.
- [ ] 첫 inbound `packets.HeartBeat`를 감지하면 OTO agent lifecycle을 online으로 한 번만 전환하고 node event를 발행한다.
- [ ] unknown token, duplicate connection, heartbeat timeout/transport close가 서로 다른 reason/metadata로 관측되게 한다.
- [ ] service snapshot과 관련 테스트가 accepted/online/failed lifecycle 값을 보존하는지 검증한다.
- [ ] `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [API-1] Lifecycle Constants and Registry Update

문제: [registry.go](/config/workspace/iop2oto/apps/edge/internal/node/registry.go:15)는 `LifecycleConnected`만 정의하고, [registry.go](/config/workspace/iop2oto/apps/edge/internal/node/registry.go:54)는 빈 lifecycle을 모두 connected로 채운다.

해결 방법: `LifecycleAccepted`, `LifecycleOnline`, `LifecycleFailed`를 추가하고 `UpdateLifecycle(nodeID, state string) bool`를 Registry에 추가한다. `LifecycleConnected`는 generic node 호환 alias로 유지한다.

Before:

```go
// apps/edge/internal/node/registry.go:15
const LifecycleConnected = "connected"
```

After:

```go
const (
    LifecycleAccepted  = "accepted"
    LifecycleOnline    = "online"
    LifecycleFailed    = "failed"
    LifecycleConnected = "connected"
)
```

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/node/registry.go` lifecycle constants 추가.
- [ ] `apps/edge/internal/node/registry.go` `UpdateLifecycle` 추가.
- [ ] `apps/edge/internal/node/registry_test.go` default lifecycle과 update lifecycle 테스트 추가/수정.

테스트 작성: `TestRegistryUpdateLifecycle` 추가. unknown node는 false, existing node는 state 변경과 snapshot 보존을 확인한다.

중간 검증:

```bash
go test -count=1 ./apps/edge/internal/node
```

예상 결과: exit code 0.

### [API-2] Registration Accepted Versus Online

문제: [server.go](/config/workspace/iop2oto/apps/edge/internal/transport/server.go:164)는 OTO entry를 만들 때 lifecycle을 지정하지 않아 registry default가 connected가 된다. [server.go](/config/workspace/iop2oto/apps/edge/internal/transport/server.go:205)는 등록 event에 lifecycle metadata도 남기지 않는다.

해결 방법: OTO agent는 registration response 직전 `LifecycleAccepted`, generic node는 기존 `LifecycleConnected`로 등록한다. 등록 event metadata에 `agent_kind`와 `lifecycle_state`를 넣는다.

Before:

```go
// apps/edge/internal/transport/server.go:164
entry := &edgenode.NodeEntry{
    NodeID:    rec.ID,
    Alias:     rec.Alias,
    AgentKind: rec.AgentKind,
    Client:    client,
    Index:     rec.Index,
    HasIndex:  true,
}
```

After:

```go
lifecycle := edgenode.LifecycleConnected
if rec.AgentKind == config.AgentKindOTOAgent {
    lifecycle = edgenode.LifecycleAccepted
}
entry := &edgenode.NodeEntry{
    NodeID:         rec.ID,
    Alias:          rec.Alias,
    AgentKind:      rec.AgentKind,
    LifecycleState: lifecycle,
    Client:         client,
    Index:          rec.Index,
    HasIndex:       true,
}
```

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/transport/server.go` lifecycle 결정 helper 추가.
- [ ] `apps/edge/internal/transport/server.go` registration event metadata 추가.
- [ ] `apps/edge/internal/transport/server_test.go` OTO registration lifecycle 기대값을 accepted로 변경.
- [ ] `apps/edge/internal/transport/integration_test.go` generic node는 connected 유지 검증.

테스트 작성: `TestEdgeServerOTORegistrationKind`를 accepted 기대값으로 수정하고 connected generic integration 회귀를 유지한다.

중간 검증:

```bash
go test -count=1 ./apps/edge/internal/transport
```

예상 결과: exit code 0.

### [API-3] First Heartbeat Online Transition

문제: `../proto-socket/go/tcp_client.go:40`에서 내부 `packets.HeartBeat` listener가 등록되지만 Edge transport는 별도 listener가 없어 첫 heartbeat를 상태 전환으로 관측하지 않는다.

해결 방법: `git.toki-labs.com/toki/proto-socket/go/packets`를 import하고 OTO registration 이후 `toki.AddListenerTyped[*packets.HeartBeat]`를 추가한다. 첫 heartbeat에서만 registry lifecycle을 `online`으로 바꾸고 `node.online` event를 emit한다.

Before:

```go
// apps/edge/internal/transport/server.go:172
toki.AddListenerTyped[*iop.EdgeNodeEvent](&client.Communicator, func(e *iop.EdgeNodeEvent) {
    if e.NodeId == "" {
        e.NodeId = rec.ID
    }
```

After:

```go
if rec.AgentKind == config.AgentKindOTOAgent {
    var onlineOnce sync.Once
    toki.AddListenerTyped[*packets.HeartBeat](&client.Communicator, func(*packets.HeartBeat) {
        onlineOnce.Do(func() {
            if s.registry.UpdateLifecycle(rec.ID, edgenode.LifecycleOnline) {
                s.emitNodeEvent(events.NewEdgeNodeEvent(events.SourceEdge, events.TypeNodeOnline, rec.ID, rec.Alias, events.ReasonFirstHeartbeat, lifecycleMetadata(rec, edgenode.LifecycleOnline)))
            }
        })
    })
}
```

수정 파일 및 체크리스트:

- [ ] `packages/go/events/events.go` `TypeNodeOnline`, `ReasonFirstHeartbeat`, lifecycle metadata key 상수 추가.
- [ ] `apps/edge/internal/transport/server.go` heartbeat listener 추가.
- [ ] `apps/edge/internal/transport/server_test.go` 또는 `integration_test.go`에서 raw heartbeat frame을 보내 online 전환과 event 확인.
- [ ] `apps/edge/internal/service/service_test.go` snapshot이 online lifecycle을 전달하는지 확인.

테스트 작성: `TestEdgeServerOTOFirstHeartbeatMarksOnline` 추가. OTO register 후 entry lifecycle accepted, heartbeat frame 송신 후 lifecycle online 및 `node.online` event를 확인한다.

중간 검증:

```bash
go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/service ./packages/go/events
```

예상 결과: exit code 0.

### [API-4] Failure Reasons

문제: [server.go](/config/workspace/iop2oto/apps/edge/internal/transport/server.go:137)는 unknown token을 response reason으로만 돌려주고 lifecycle event를 남기지 않는다. duplicate는 OTO/generic reason이 다르지만 event evidence가 없다. heartbeat timeout은 disconnect metadata로만 있다.

해결 방법: rejected registration은 registry에 넣지 않되 `node.registration_failed` event를 emit한다. metadata에는 `failure_reason=unknown_token|duplicate_connection|heartbeat_timeout`, `agent_kind` 가능한 경우, token prefix만 포함한다. heartbeat timeout disconnect는 lifecycle failed로 전환하고 metadata에 lifecycle/failure reason을 보강한다.

수정 파일 및 체크리스트:

- [ ] `packages/go/events/events.go` `TypeNodeRegistrationFailed`, `ReasonUnknownToken`, `ReasonDuplicateConnection`, metadata key 상수 추가.
- [ ] `apps/edge/internal/transport/server.go` unknown token/duplicate reject event emit.
- [ ] `apps/edge/internal/transport/server.go` disconnect path에서 heartbeat timeout이면 lifecycle failed로 전환.
- [ ] `apps/edge/internal/transport/server_test.go` unknown token, duplicate OTO, heartbeat timeout metadata 테스트 추가.

테스트 작성: unknown token reject event, duplicate reject event, heartbeat timeout disconnect metadata를 각각 검증한다. heartbeat timeout은 interval 조절이 어려우면 `transportDisconnectMetadata`/disconnect info helper 단위 테스트로 시작하되 integration TODO를 남기지 말고 실제 observable event path를 검증한다.

중간 검증:

```bash
go test -count=1 ./apps/edge/internal/transport
```

예상 결과: exit code 0.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/node/registry.go` | API-1 |
| `apps/edge/internal/node/registry_test.go` | API-1 |
| `apps/edge/internal/transport/server.go` | API-2, API-3, API-4 |
| `apps/edge/internal/transport/server_test.go` | API-2, API-3, API-4 |
| `apps/edge/internal/transport/integration_test.go` | API-2 |
| `apps/edge/internal/service/service_test.go` | API-3 |
| `packages/go/events/events.go` | API-3, API-4 |
| `packages/go/events/events_test.go` | API-3, API-4 |

## 최종 검증

```bash
go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events
```

예상 결과: exit code 0. Go test cache output은 허용하지 않는다.

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