feat: oto-registration-online-smoke milestone 및 edge lifecycle 관련 수정

- update roadmap milestone for oto-registration-online-smoke
- fix edge node registry and transport integration
- update events package
- add task evidence and review documents
This commit is contained in:
toki 2026-06-03 22:09:11 +09:00
parent 482c4f198a
commit b8ad51f4b8
15 changed files with 1751 additions and 32 deletions

View file

@ -61,4 +61,5 @@ Edge와 OTO가 같은 기준으로 online/failed 상태를 판정한다.
- 표준선(선택): heartbeat timeout은 transport close reason과 lifecycle event metadata로 남긴다. - 표준선(선택): heartbeat timeout은 transport close reason과 lifecycle event metadata로 남긴다.
- 선행 작업: OTO Agent Registry 구현, OTO Bootstrap Command 발급 - 선행 작업: OTO Agent Registry 구현, OTO Bootstrap Command 발급
- 후속 작업: OTO Agent Message Boundary, OTO repo `oto-agent` 등록 흐름 잠금 해제 검토 - 후속 작업: OTO Agent Message Boundary, OTO repo `oto-agent` 등록 흐름 잠금 해제 검토
- 확인 필요: smoke 실행 시 iop Edge와 OTO를 같은 host에서 띄울지, split host로 띄울지 - 확인 결과: local smoke의 기본 기준은 같은 host에서 iop Edge와 OTO를 띄우는 흐름으로 두고, split host는 주소/포트 override가 필요한 후속 또는 수동 검증 경로로 분리한다.
- 실행 계획: 큰 작업은 `agent-task/m-oto-registration-online-smoke/01_iop_edge_lifecycle`, `agent-task/m-oto-registration-online-smoke/02+01_oto_agent_session`, `agent-task/m-oto-registration-online-smoke/03+01,02_smoke_evidence`로 분리해 plan/code-review 기준을 작성했다.

View file

@ -0,0 +1,171 @@
<!-- task=m-oto-registration-online-smoke/01_iop_edge_lifecycle plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-03
task=m-oto-registration-online-smoke/01_iop_edge_lifecycle, plan=0, tag=API
## 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
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` → `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-oto-registration-online-smoke/01_iop_edge_lifecycle/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Lifecycle Constants and Registry Update | [x] |
| [API-2] Registration Accepted Versus Online | [x] |
| [API-3] First Heartbeat Online Transition | [x] |
| [API-4] Failure Reasons | [x] |
## 구현 체크리스트
- [x] OTO agent 등록 직후 lifecycle을 accepted로 기록하고 generic node 기본 connected 동작은 유지한다.
- [x] 첫 inbound `packets.HeartBeat`를 감지하면 OTO agent lifecycle을 online으로 한 번만 전환하고 node event를 발행한다.
- [x] unknown token, duplicate connection, heartbeat timeout/transport close가 서로 다른 reason/metadata로 관측되게 한다.
- [x] service snapshot과 관련 테스트가 accepted/online/failed lifecycle 값을 보존하는지 검증한다.
- [x] `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-oto-registration-online-smoke/01_iop_edge_lifecycle/`를 `agent-task/archive/YYYY/MM/m-oto-registration-online-smoke/01_iop_edge_lifecycle/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-oto-registration-online-smoke/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- 테스트 시 heartbeat timeout의 빠른 유도를 위해 `server.go`에 `HeartbeatInterval`과 `HeartbeatWait` 필드를 Export하여 테스트 코드가 1초로 짧은 타임아웃 주기를 지정할 수 있도록 필드를 노출함.
- `TestEdgeServerHeartbeatTimeoutDisconnect` 테스트 시, 클라이언트 측의 `packets.HeartBeat` 수신 리스너를 제거(`client.RemoveListeners`)하여 서버 측의 HeartBeat 송신에 응답하지 않게 함으로써 heartbeat timeout을 안정적이고 빠르게 유도함. 최초 connection 시 automatically 송신된 heartbeat 이후, 클라이언트가 수신 대기를 무시하므로 바로 disconnected(failed)로 넘어가게 되며, 이에 맞춰 online event 확인 블록은 제외함.
## 주요 설계 결정
- OTO agent 등록 시 generic node의 connected 기본값과 명시적으로 분리하여 `LifecycleAccepted` 상태로 최초 등록 처리.
- heartbeat timeout 테스트의 빠른 실행과 E2E smoke 관측을 위해 `Server` 구조체 내에 heartbeat interval/wait 필드를 Export함으로써 테스트용 override가 용이하도록 개선함.
- 클라이언트 측의 `RemoveListeners` 메서드를 사용하여 mock 커넥션을 짜지 않고도 실제 `toki.TcpClient` 연결 내에서 heartbeat 응답 거부를 성공적으로 구현함.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- OTO registration accepted와 online이 lifecycle state와 event에서 분리되는가?
- `LifecycleConnected` generic node 호환이 깨지지 않는가?
- HeartBeat listener가 첫 heartbeat에만 online event를 발행하고 중복 발행하지 않는가?
- failure reason metadata가 token 원문을 노출하지 않는가?
## 검증 결과
### API-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/node
```
```
ok iop/apps/edge/internal/node 0.008s
```
### API-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/transport
```
```
ok iop/apps/edge/internal/transport 0.011s
```
### API-3 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/service ./packages/go/events
```
```
ok iop/apps/edge/internal/transport 2.018s
ok iop/apps/edge/internal/service 0.007s
? iop/packages/go/events [no test files]
```
### API-4 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/transport
```
```
ok iop/apps/edge/internal/transport 2.018s
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events
```
```
ok iop/apps/edge/internal/transport 2.018s
ok iop/apps/edge/internal/node 0.010s
ok iop/apps/edge/internal/service 0.007s
? iop/packages/go/events [no test files]
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: WARN
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Warn
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제:
- Suggested: [registry_test.go](/config/workspace/iop2oto/apps/edge/internal/node/registry_test.go:132) and [integration_test.go](/config/workspace/iop2oto/apps/edge/internal/transport/integration_test.go:359) are not gofmt-clean. `gofmt -l apps/edge/internal/node/registry_test.go apps/edge/internal/transport/integration_test.go` returns both files, so run gofmt on the touched Go files and rerun the targeted verification.
- 다음 단계: WARN 후속으로 `PLAN-local-G01.md`와 `CODE_REVIEW-local-G01.md`를 작성한다.

View file

@ -0,0 +1,133 @@
<!-- task=m-oto-registration-online-smoke/01_iop_edge_lifecycle plan=1 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-03
task=m-oto-registration-online-smoke/01_iop_edge_lifecycle, plan=1, tag=REVIEW_API
## 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
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G01.md` -> `code_review_local_G01_N.log`, `PLAN-local-G01.md` -> `plan_local_G01_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-oto-registration-online-smoke/01_iop_edge_lifecycle/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] gofmt Cleanup | [x] |
## 구현 체크리스트
- [x] `gofmt -w apps/edge/internal/node/registry_test.go apps/edge/internal/transport/integration_test.go`를 실행한다.
- [x] `gofmt -l apps/edge/internal/node/registry.go apps/edge/internal/node/registry_test.go apps/edge/internal/transport/server.go apps/edge/internal/transport/integration_test.go packages/go/events/events.go`가 빈 출력인지 확인한다.
- [x] `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G01_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G01_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-oto-registration-online-smoke/01_iop_edge_lifecycle/`를 `agent-task/archive/YYYY/MM/m-oto-registration-online-smoke/01_iop_edge_lifecycle/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-oto-registration-online-smoke/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
계획 그대로 `apps/edge/internal/node/registry_test.go`와 `apps/edge/internal/transport/integration_test.go`에 `gofmt -w`를 적용했다. source behavior, test assertions, event constants, registry/transport logic은 변경하지 않았다.
## 주요 설계 결정
formatting-only 변경이므로 설계 결정은 없다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `gofmt -l`이 빈 출력인가? 예, 빈 출력 확인.
- formatting-only 변경 외에 동작 변경이 끼어들지 않았는가? 예, formatting-only.
- targeted Go 테스트가 통과했는가? 예.
## 검증 결과
### gofmt -l (변경된 파일들 기준)
```
$ gofmt -l apps/edge/internal/node/registry.go apps/edge/internal/node/registry_test.go apps/edge/internal/transport/server.go apps/edge/internal/transport/integration_test.go packages/go/events/events.go
(no output)
```
### go test -count=1
```
$ go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events
ok iop/apps/edge/internal/transport 2.026s
ok iop/apps/edge/internal/node 0.006s
ok iop/apps/edge/internal/service 0.008s
? iop/packages/go/events [no test files]
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS 종결. `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,45 @@
# Complete - m-oto-registration-online-smoke/01_iop_edge_lifecycle
## 완료 일시
2026-06-03
## 요약
OTO Edge lifecycle foundation subtask completed after 2 review loops; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | WARN | Lifecycle/event implementation and verification passed, but touched Go test files were not gofmt-clean. |
| `plan_local_G01_1.log` | `code_review_local_G01_1.log` | PASS | gofmt cleanup completed; formatter, whitespace, and targeted Go verification passed. |
## 구현/정리 내용
- Added accepted/online/failed lifecycle states while preserving generic node connected behavior.
- Added OTO accepted registration metadata, first-heartbeat online transition, and distinct registration/heartbeat failure reason metadata.
- Added registry, transport, and service-adjacent tests for lifecycle state preservation and failure reason observability.
- Applied gofmt cleanup for the follow-up WARN finding.
## 최종 검증
- `gofmt -l apps/edge/internal/node/registry.go apps/edge/internal/node/registry_test.go apps/edge/internal/transport/server.go apps/edge/internal/transport/integration_test.go packages/go/events/events.go` - PASS; empty output.
- `git diff --check` - PASS; empty output.
- `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events` - PASS; `ok` for transport, node, service and `[no test files]` for events.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/oto-registration-online-smoke.md`
- Completed task ids:
- `accepted-state`: PASS; evidence=`agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/code_review_cloud_G07_0.log`, `agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/plan_local_G01_1.log`, `agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/code_review_local_G01_1.log`; verification=`go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events`
- `failure-reason`: PASS; evidence=`agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/code_review_cloud_G07_0.log`, `agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/plan_local_G01_1.log`, `agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/code_review_local_G01_1.log`; verification=`go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,289 @@
<!-- 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,58 @@
<!-- task=m-oto-registration-online-smoke/01_iop_edge_lifecycle plan=1 tag=REVIEW_API -->
# Follow-up Plan - REVIEW_API
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것까지가 구현입니다. 코드를 바꾼 뒤 검증을 실행하고, 실제 변경 내용과 stdout/stderr를 리뷰 stub에 기록한 상태로 멈추세요. 종결 판정, log rename, `complete.log`, archive 이동은 code-review 스킬 전용입니다. 구현 중 사용자 결정, 사용자 소유 외부 환경, 범위 충돌이 없으면 `사용자 리뷰 요청`을 만들지 않습니다.
## 배경
이전 리뷰 `code_review_cloud_G07_0.log`는 lifecycle/event 구현과 targeted/Edge/package 회귀 검증은 통과했지만, `gofmt -l`가 변경된 Go test 파일 두 개를 반환해 WARN으로 종료했다. 이번 후속은 동작 변경 없이 formatting을 정리하고 같은 targeted 검증을 재실행하는 작업이다.
## 사용자 리뷰 요청 흐름
구현 중 사용자-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
## 범위 결정 근거
- source behavior, test assertions, event constants, registry/transport logic은 변경하지 않는다.
- 이전 리뷰의 Suggested issue만 해소한다.
- `agent-roadmap/**`는 수정하지 않는다.
## 구현 체크리스트
- [ ] `gofmt -w apps/edge/internal/node/registry_test.go apps/edge/internal/transport/integration_test.go`를 실행한다.
- [ ] `gofmt -l apps/edge/internal/node/registry.go apps/edge/internal/node/registry_test.go apps/edge/internal/transport/server.go apps/edge/internal/transport/integration_test.go packages/go/events/events.go`가 빈 출력인지 확인한다.
- [ ] `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_API-1] gofmt Cleanup
문제: `gofmt -l`가 [registry_test.go](/config/workspace/iop2oto/apps/edge/internal/node/registry_test.go:132)와 [integration_test.go](/config/workspace/iop2oto/apps/edge/internal/transport/integration_test.go:359)를 반환한다.
해결 방법: touched Go files 중 formatter가 지적한 두 파일에 `gofmt -w`를 적용한다.
수정 파일 및 체크리스트:
- [ ] `apps/edge/internal/node/registry_test.go` gofmt 적용.
- [ ] `apps/edge/internal/transport/integration_test.go` gofmt 적용.
테스트 작성: 없음. formatting-only 후속이다.
중간 검증:
```bash
gofmt -l apps/edge/internal/node/registry.go apps/edge/internal/node/registry_test.go apps/edge/internal/transport/server.go apps/edge/internal/transport/integration_test.go packages/go/events/events.go
go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events
```
예상 결과: `gofmt -l`은 빈 출력, `go test`는 exit code 0.

View file

@ -0,0 +1,110 @@
<!-- task=m-oto-registration-online-smoke/02+01_oto_agent_session plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only.
## 개요
date=2026-06-03
task=m-oto-registration-online-smoke/02+01_oto_agent_session, plan=0, tag=API
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
PASS이면 `complete.log` 작성 후 `agent-task/archive/YYYY/MM/m-oto-registration-online-smoke/02+01_oto_agent_session/`로 이동합니다. roadmap 수정은 런타임 책임입니다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Dependency Alignment | [ ] |
| [API-2] Persistent Edge Session API | [ ] |
| [API-3] Runner Keeps Agent Alive | [ ] |
## 구현 체크리스트
- [ ] `../oto/pubspec.yaml`의 protobuf constraint를 path dependency `proto_socket`과 맞춰 tests가 resolve되게 한다.
- [ ] 기존 `register()` 호환을 유지하면서 close하지 않는 OTO registration session API를 추가한다.
- [ ] `DefaultAgentRunner.run()`이 registration 성공 후 agent connection을 유지하고 종료 신호 또는 cancellation에서 닫도록 조정한다.
- [ ] OTO agent unit tests가 persistent session API와 rejected registration behavior를 검증하게 한다.
- [ ] `cd ../oto && dart test test/oto_agent_registration_test.dart test/oto_agent_config_test.dart`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G06_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G06_M.log`로 아카이브한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-oto-registration-online-smoke/02+01_oto_agent_session/`를 archive로 이동한다.
- [ ] PASS split 작업이면 이동 후 parent에 남은 sibling/file이 있어 유지했다고 확인한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- 기존 one-shot `register()` API와 tests가 유지되는가?
- persistent session이 registration failure에서는 leak 없이 닫히는가?
- runner가 test-injectable wait/stop 경계를 가져서 unit test가 hang하지 않는가?
- `pubspec.yaml` dependency update가 generated protobuf를 수동 편집하지 않고 해결되는가?
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증
```bash
$ cd ../oto && dart pub get
```
### API-2 중간 검증
```bash
$ cd ../oto && dart test test/oto_agent_registration_test.dart
```
### API-3 중간 검증
```bash
$ cd ../oto && dart test test/oto_agent_registration_test.dart
```
### 최종 검증
```bash
$ cd ../oto && dart pub get
$ cd ../oto && dart analyze
$ cd ../oto && dart test test/oto_agent_registration_test.dart test/oto_agent_config_test.dart
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**

View file

@ -0,0 +1,198 @@
<!-- task=m-oto-registration-online-smoke/02+01_oto_agent_session plan=0 tag=API -->
# OTO Agent Persistent Registration Session
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것까지가 구현입니다. 검증 출력은 실제 stdout/stderr를 붙이고 active 파일을 남긴 채 리뷰 요청 상태로 보고하세요. 종결, archive, `complete.log` 작성은 code-review 스킬 전용입니다.
## 배경
OTO `EdgeRegistrationClient.register()`는 registration response를 받은 뒤 client를 즉시 닫는다. iop Milestone은 registration accepted 이후 첫 heartbeat까지 살아 있는 OTO agent를 요구하므로, OTO agent 도메인에 persistent session 경계가 필요하다. 현재 OTO tests는 `protobuf ^3.1.0``proto_socket``protobuf ^6.0.0` 요구가 충돌해 baseline 실행도 막힌다.
## 사용자 리뷰 요청 흐름
구현 중 사용자-only blocker가 있으면 active review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정, 근거, 재개 조건을 남기고 멈춘다. dependency solving이나 test rerun으로 해소 가능한 증거 공백은 사용자 리뷰 요청이 아니다.
## 분석 결과
### 읽은 파일
- `agent-roadmap/phase/automation-runtime-bridge/milestones/oto-registration-online-smoke.md`
- `../oto/AGENTS.md`
- `../oto/agent-ops/rules/project/rules.md`
- `../oto/agent-ops/rules/project/domain/agent/rules.md`
- `../oto/agent-ops/rules/project/domain/framework/rules.md`
- `../oto/pubspec.yaml`
- `../oto/lib/oto/agent/edge_registration_client.dart`
- `../oto/lib/oto/agent/agent_runner.dart`
- `../oto/lib/oto/agent/agent_config.dart`
- `../oto/lib/cli/commands/command_agent.dart`
- `../oto/test/oto_agent_registration_test.dart`
- `../oto/test/oto_agent_config_test.dart`
- `../oto/test/oto_iop_connection_smoke_test.dart`
- `../proto-socket/go/base_client.go`
- `../proto-socket/go/tcp_client.go`
- `../proto-socket/go/communicator.go`
### 테스트 환경 규칙
- OTO `agent-test/local/rules.md`는 없다.
- 적용 fallback: OTO agent domain rule의 `dart analyze`와 agent 관련 tests.
- preflight: `command -v dart` 성공, Dart SDK `3.11.3`.
- preflight failure: `cd ../oto && dart test test/oto_agent_registration_test.dart test/oto_agent_config_test.dart``protobuf ^3.1.0`과 path dependency `proto_socket``protobuf ^6.0.0` 충돌로 exit code 65.
### 테스트 커버리지 공백
- OTO persistent session: 없음. `EdgeRegistrationClient.register()`는 [edge_registration_client.dart](/config/workspace/oto/lib/oto/agent/edge_registration_client.dart:118)에서 즉시 close한다.
- Runner daemon behavior: `DefaultAgentRunner.run()`은 registration 성공 로그 후 return한다.
- Existing smoke: `oto_iop_connection_smoke_test.dart`는 accepted response와 runtime config만 확인하고 heartbeat/online evidence는 없다.
- Dependency baseline: tests가 dependency solving 전에 막혀 test coverage가 실행 불가 상태다.
### 심볼 참조
- 변경 후보: `EdgeRegistrationClient.register` override/call sites는 `../oto/test/oto_agent_registration_test.dart`, `../oto/test/oto_iop_connection_smoke_test.dart`, `../oto/lib/oto/agent/agent_runner.dart`에 있다.
- 제거하지 말 것: 기존 one-shot `register()` API는 unit test와 runner injection 호환을 위해 유지하거나 wrapper로 보존한다.
### 분할 판단
- 이 subtask는 `02+01_oto_agent_session`이며 predecessor `01_iop_edge_lifecycle` 완료가 필요하다.
- predecessor check: 아직 `agent-task/m-oto-registration-online-smoke/01_iop_edge_lifecycle/complete.log` 없음. 구현 시작 전 active/archive candidate `01_*` complete.log를 확인해야 한다.
- split 이유: OTO dependency/session 변경은 iop Edge lifecycle foundation과 repo/domain이 다르고 검증 실패 원인이 다르다.
### 범위 결정 근거
- OTO pipeline build/run payload, artifact/log stream은 Milestone 범위 제외이므로 구현하지 않는다.
- scheduler/background service manager 정책은 건드리지 않는다.
- generated protobuf Dart 파일은 직접 수정하지 않는다.
- iop Edge code는 predecessor subtask에서만 수정한다.
### 빌드 등급
- build: `cloud-G06`, review: `cloud-G06`. Dart agent lifecycle과 dependency alignment가 cross-repo로 묶여 있고, tests baseline blocker 해소가 필요하다.
## 의존 관계 및 구현 순서
이 subtask는 디렉터리명 `02+01_oto_agent_session`에 따라 `01_iop_edge_lifecycle``complete.log`가 필요하다. 구현 시작 전 아래 중 정확히 하나를 확인한다.
- active: `agent-task/m-oto-registration-online-smoke/01_*/complete.log`
- archive: `agent-task/archive/*/*/m-oto-registration-online-smoke/01_*/complete.log`
## 구현 체크리스트
- [ ] `../oto/pubspec.yaml`의 protobuf constraint를 path dependency `proto_socket`과 맞춰 tests가 resolve되게 한다.
- [ ] 기존 `register()` 호환을 유지하면서 close하지 않는 OTO registration session API를 추가한다.
- [ ] `DefaultAgentRunner.run()`이 registration 성공 후 agent connection을 유지하고 종료 신호 또는 cancellation에서 닫도록 조정한다.
- [ ] OTO agent unit tests가 persistent session API와 rejected registration behavior를 검증하게 한다.
- [ ] `cd ../oto && dart test test/oto_agent_registration_test.dart test/oto_agent_config_test.dart`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] Dependency Alignment
문제: [pubspec.yaml](/config/workspace/oto/pubspec.yaml:23)은 `proto_socket` path dependency를 쓰고, [pubspec.yaml](/config/workspace/oto/pubspec.yaml:30)은 `protobuf ^3.1.0`을 고정한다. 현재 tests는 `proto_socket``protobuf ^6.0.0` 요구와 충돌한다.
해결 방법: `protobuf` constraint를 `^6.0.0`으로 올리고 `dart pub get` 후 agent tests를 실행한다.
수정 파일 및 체크리스트:
- [ ] `../oto/pubspec.yaml` protobuf constraint 업데이트.
- [ ] `../oto/pubspec.lock`이 생성/변경되면 repo 정책에 맞춰 포함 여부 확인.
테스트 작성: SKIP. dependency alignment는 기존 agent tests 실행 가능 여부로 검증한다.
중간 검증:
```bash
cd ../oto && dart pub get
```
예상 결과: exit code 0.
### [API-2] Persistent Edge Session API
문제: [edge_registration_client.dart](/config/workspace/oto/lib/oto/agent/edge_registration_client.dart:107)는 `register()` 하나만 제공하고, [edge_registration_client.dart](/config/workspace/oto/lib/oto/agent/edge_registration_client.dart:118)는 response 직후 close한다.
해결 방법: `EdgeAgentSession` 또는 `RegistrationSession` 타입을 추가한다. 새 API는 connect/register 후 client를 유지하고 `close()`를 노출한다. 기존 `register()`는 새 API를 사용하되 finally에서 close하는 one-shot wrapper로 보존한다.
Before:
```dart
// ../oto/lib/oto/agent/edge_registration_client.dart:107
Future<RegistrationResult> register(AgentConfig config) async {
final endpoint = EdgeEndpoint.parse(config.edge.url);
final socket = await ProtobufClient.connect(endpoint.host, endpoint.port);
final client = _OtoIopClient(socket);
try {
final response =
await client.sendRequest<iop.RegisterRequest, iop.RegisterResponse>(
iop.RegisterRequest()..token = config.agent.enrollmentToken,
timeout: const Duration(seconds: 5),
);
return RegistrationResult.fromResponse(response);
} finally {
await client.close();
}
}
```
수정 파일 및 체크리스트:
- [ ] `../oto/lib/oto/agent/edge_registration_client.dart` persistent session 타입 추가.
- [ ] `../oto/lib/oto/agent/edge_registration_client.dart` one-shot `register()` 호환 보존.
- [ ] `../oto/test/oto_agent_registration_test.dart` fake client 구조를 새 API와 호환되게 수정.
테스트 작성: `EdgeRegistrationClient` fake/session close test를 추가하거나 runner test에서 close 호출을 검증한다.
중간 검증:
```bash
cd ../oto && dart test test/oto_agent_registration_test.dart
```
예상 결과: exit code 0.
### [API-3] Runner Keeps Agent Alive
문제: [agent_runner.dart](/config/workspace/oto/lib/oto/agent/agent_runner.dart:40)는 registration success 로그 후 return한다. CLI help는 daemon이라고 말하지만 실제 연결은 유지되지 않는다.
해결 방법: `DefaultAgentRunner.run()`은 persistent session API를 사용하고, 성공 후 process signal/cancellation 경계까지 대기한다. 테스트 가능성을 위해 runner에 optional wait future 또는 stop signal injection을 둔다.
수정 파일 및 체크리스트:
- [ ] `../oto/lib/oto/agent/agent_runner.dart` persistent session 사용.
- [ ] `../oto/lib/oto/agent/agent_runner.dart` test injection 가능한 wait/stop hook 추가.
- [ ] `../oto/test/oto_agent_registration_test.dart` success path가 session close까지 호출하는지 검증.
- [ ] `../oto/lib/cli/commands/command_agent.dart` 변경이 필요하면 기존 `--config` parsing 유지.
테스트 작성: fake client가 persistent session을 반환하고 runner wait future 완료 시 close되는지 확인한다.
중간 검증:
```bash
cd ../oto && dart test test/oto_agent_registration_test.dart
```
예상 결과: exit code 0.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `../oto/pubspec.yaml` | API-1 |
| `../oto/pubspec.lock` | API-1 |
| `../oto/lib/oto/agent/edge_registration_client.dart` | API-2 |
| `../oto/lib/oto/agent/agent_runner.dart` | API-3 |
| `../oto/lib/cli/commands/command_agent.dart` | API-3, only if runner call-site needs adjustment |
| `../oto/test/oto_agent_registration_test.dart` | API-2, API-3 |
## 최종 검증
```bash
cd ../oto && dart pub get
cd ../oto && dart analyze
cd ../oto && dart test test/oto_agent_registration_test.dart test/oto_agent_config_test.dart
```
예상 결과: 모두 exit code 0. Dart dependency solving output은 반드시 실제 출력으로 기록한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,117 @@
<!-- task=m-oto-registration-online-smoke/03+01,02_smoke_evidence plan=0 tag=TEST -->
# Code Review Reference - TEST
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only.
## 개요
date=2026-06-03
task=m-oto-registration-online-smoke/03+01,02_smoke_evidence, plan=0, tag=TEST
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/oto-registration-online-smoke.md`
- Task ids:
- `heartbeat-online`: 최초 heartbeat 도착 후 online으로 전환된다. 검증: OTO Dart smoke 또는 통합 smoke에서 registration accepted 뒤 heartbeat 도착까지 확인한다.
- `unlock-evidence`: OTO `oto-agent` 등록 흐름 잠금 해제에 사용할 smoke 결과와 실행 명령이 문서 또는 test log에 남는다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
PASS이면 `complete.log` 작성 후 `agent-task/archive/YYYY/MM/m-oto-registration-online-smoke/03+01,02_smoke_evidence/`로 이동합니다. roadmap 수정은 런타임 책임입니다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [TEST-1] Make OTO Smoke Repo Path Explicit | [ ] |
| [TEST-2] Assert Accepted Then Heartbeat Online | [ ] |
| [TEST-3] iop Makefile Target and Evidence Command | [ ] |
## 구현 체크리스트
- [ ] OTO smoke가 iop repo path를 env로 받을 수 있게 하거나 iop-side wrapper가 현재 repo를 명시하게 한다.
- [ ] OTO Dart smoke 또는 iop wrapper가 registration accepted 뒤 heartbeat/online event를 확인한다.
- [ ] `make test-oto-registration-online-smoke` 또는 동등한 iop Makefile target을 추가한다.
- [ ] smoke output에 unlock handoff용 실행 명령과 accepted/heartbeat/online evidence가 남게 한다.
- [ ] `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events`와 OTO smoke target을 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G07_M.log`로 아카이브한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-oto-registration-online-smoke/03+01,02_smoke_evidence/`를 archive로 이동한다.
- [ ] PASS split 작업이면 이동 후 parent에 남은 sibling/file이 있어 유지했다고 확인한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- predecessor complete.log가 `01``02` 모두 있었는가?
- smoke가 same-host 기본 경로를 사용하고 split-host를 기본값으로 요구하지 않는가?
- output에 registration accepted, first heartbeat, online evidence가 모두 남는가?
- token/secret 원문이 tracked docs나 logs에 남지 않는가?
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### TEST-1 중간 검증
```bash
$ rg --sort path "../iop|IOP_REPO_ROOT|agent_kind" ../oto/test/oto_iop_connection_smoke_test.dart
```
### TEST-2 중간 검증
```bash
$ cd ../oto && IOP_REPO_ROOT=/config/workspace/iop2oto dart test test/oto_iop_connection_smoke_test.dart
```
### TEST-3 중간 검증
```bash
$ make test-oto-registration-online-smoke
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events
$ cd ../oto && dart analyze
$ make test-oto-registration-online-smoke
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**

View file

@ -0,0 +1,184 @@
<!-- task=m-oto-registration-online-smoke/03+01,02_smoke_evidence plan=0 tag=TEST -->
# OTO Registration Online Smoke Evidence
## 이 파일을 읽는 구현 에이전트에게
이 plan은 실제 smoke/evidence 수집을 끝내는 작업입니다. 구현 후 active `CODE_REVIEW-*-G??.md`의 구현 소유 섹션에 실행 명령과 실제 stdout/stderr를 남기고, code-review가 PASS/complete/archive를 처리하게 두세요.
## 배경
iop Edge lifecycle foundation과 OTO persistent session이 완료되어도 Milestone은 실제 same-host smoke evidence가 남아야 완료될 수 있다. 현재 OTO smoke는 accepted response만 확인하고, iop repo에는 OTO smoke를 재현하는 공식 Makefile/script entrypoint가 없다. 이 subtask는 same-host 기본 smoke를 재현 가능한 명령으로 묶고, heartbeat online과 unlock evidence를 남긴다.
## 사용자 리뷰 요청 흐름
사용자-owned 외부 host, credential, private endpoint가 꼭 필요해지면 review stub의 `사용자 리뷰 요청`에 근거를 남긴다. local same-host smoke에서 해결 가능한 실패는 사용자 리뷰 요청이 아니라 후속 구현/검증 이슈다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/oto-registration-online-smoke.md`
- Task ids:
- `heartbeat-online`: 최초 heartbeat 도착 후 online으로 전환된다. 검증: OTO Dart smoke 또는 통합 smoke에서 registration accepted 뒤 heartbeat 도착까지 확인한다.
- `unlock-evidence`: OTO `oto-agent` 등록 흐름 잠금 해제에 사용할 smoke 결과와 실행 명령이 문서 또는 test log에 남는다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/phase/automation-runtime-bridge/milestones/oto-registration-online-smoke.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/testing-smoke.md`
- `Makefile`
- `../oto/AGENTS.md`
- `../oto/agent-ops/rules/project/rules.md`
- `../oto/agent-ops/rules/project/domain/agent/rules.md`
- `../oto/agent-ops/rules/project/domain/framework/rules.md`
- `../oto/pubspec.yaml`
- `../oto/lib/oto/agent/edge_registration_client.dart`
- `../oto/lib/oto/agent/agent_runner.dart`
- `../oto/test/oto_iop_connection_smoke_test.dart`
- `scripts/e2e-smoke.sh` 일부: 기존 generic node smoke 패턴 확인용. 이 plan에서는 직접 수정하지 않는다.
### 테스트 환경 규칙
- iop test_env: `local`, profiles `edge-smoke`, `testing-smoke`.
- iop required commands: target Go tests, changed user entrypoint full-cycle, optional `make test-e2e`.
- OTO `agent-test/local`은 없음. OTO domain fallback: `dart analyze`, agent tests, `test/oto_iop_connection_smoke_test.dart`.
- preflight: `command -v dart` 성공, but current OTO dependency solving failed before subtask 02.
### 테스트 커버리지 공백
- OTO smoke does not assert heartbeat or Edge online event.
- iop Makefile lacks an OTO-specific smoke target.
- Current OTO smoke hardcodes `workingDirectory: '../iop'`, which is fragile for this workspace where current repo is `iop2oto`.
- No tracked evidence file/log command exists for OTO unlock handoff.
### 심볼 참조
- 변경/추가 후보: Makefile `.PHONY` and test targets; no symbol rename expected.
- OTO smoke references `EdgeRegistrationClient.register()` at `../oto/test/oto_iop_connection_smoke_test.dart:64`.
### 분할 판단
- This subtask is `03+01,02_smoke_evidence`.
- Required predecessors:
- `01_iop_edge_lifecycle`: no active/archive `complete.log` yet.
- `02+01_oto_agent_session`: no active/archive `complete.log` yet.
- The script/evidence work is split because it has cross-process verification and should not start until both code foundations are reviewed.
### 범위 결정 근거
- Do not merge OTO smoke into generic `scripts/e2e-smoke.sh`; that script is generic Edge-Node smoke and already large.
- Do not implement build/deploy run payload, artifact stream, or Control Plane UI.
- Do not commit secret/token values; smoke uses generated temp dirs and non-secret test token.
- Split-host smoke is out of default path; support only documented env overrides if cheap.
### 빌드 등급
- build: `cloud-G07`, review: `cloud-G07`. This is shell/CLI workflow and cross-repo process orchestration with evidence trust risk.
## 의존 관계 및 구현 순서
이 subtask는 디렉터리명 `03+01,02_smoke_evidence`에 따라 아래 predecessor complete logs가 필요하다.
- `01_iop_edge_lifecycle`: `agent-task/m-oto-registration-online-smoke/01_*/complete.log` 또는 archive equivalent.
- `02+01_oto_agent_session`: `agent-task/m-oto-registration-online-smoke/02+01_*/complete.log` 또는 archive equivalent.
구현 시작 전 둘 다 정확히 하나씩 확인한다. 없거나 ambiguous하면 구현하지 말고 review stub에 blocker를 기록한다.
## 구현 체크리스트
- [ ] OTO smoke가 iop repo path를 env로 받을 수 있게 하거나 iop-side wrapper가 현재 repo를 명시하게 한다.
- [ ] OTO Dart smoke 또는 iop wrapper가 registration accepted 뒤 heartbeat/online event를 확인한다.
- [ ] `make test-oto-registration-online-smoke` 또는 동등한 iop Makefile target을 추가한다.
- [ ] smoke output에 unlock handoff용 실행 명령과 accepted/heartbeat/online evidence가 남게 한다.
- [ ] `go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events`와 OTO smoke target을 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [TEST-1] Make OTO Smoke Repo Path Explicit
문제: [oto_iop_connection_smoke_test.dart](/config/workspace/oto/test/oto_iop_connection_smoke_test.dart:31)는 `workingDirectory: '../iop'`를 하드코딩한다. `iop2oto` clone에서는 실패한다.
해결 방법: OTO test에 `IOP_REPO_ROOT` env override를 추가하거나, iop-side wrapper에서 OTO test를 실행할 때 cwd/env를 명시한다. 기존 same-host 기본은 유지한다.
수정 파일 및 체크리스트:
- [ ] `../oto/test/oto_iop_connection_smoke_test.dart` env override 추가 또는 wrapper-only 전략 선택.
- [ ] `../oto/test/oto_iop_connection_smoke_test.dart` temp config에 `agent_kind: "oto-agent"` 포함.
- [ ] hardcoded `../iop`가 남지 않는지 `rg --sort path "../iop|IOP_REPO_ROOT" ../oto/test/oto_iop_connection_smoke_test.dart`로 확인.
테스트 작성: 기존 smoke test를 수정해 path override가 적용되는지 same test에서 사용한다.
중간 검증:
```bash
rg --sort path "../iop|IOP_REPO_ROOT|agent_kind" ../oto/test/oto_iop_connection_smoke_test.dart
```
예상 결과: `IOP_REPO_ROOT``agent_kind`가 보이고 hardcoded-only path가 없다.
### [TEST-2] Assert Accepted Then Heartbeat Online
문제: [oto_iop_connection_smoke_test.dart](/config/workspace/oto/test/oto_iop_connection_smoke_test.dart:67)는 `result.accepted`만 확인한다. online event나 heartbeat evidence가 없다.
해결 방법: OTO persistent session API로 registration 후 연결을 유지하고, iop Edge log/event output 또는 service-visible state를 확인한다. 가장 작은 경로는 Edge stdout/console log에 `node.online` or lifecycle online marker를 기다리는 것이다.
수정 파일 및 체크리스트:
- [ ] `../oto/test/oto_iop_connection_smoke_test.dart` accepted 뒤 online marker wait 추가.
- [ ] timeout failure message에 Edge output을 포함.
- [ ] test finally에서 persistent session과 Edge process를 닫는다.
테스트 작성: existing smoke test를 heartbeat online까지 확장한다.
중간 검증:
```bash
cd ../oto && IOP_REPO_ROOT=/config/workspace/iop2oto dart test test/oto_iop_connection_smoke_test.dart
```
예상 결과: exit code 0, output contains accepted and online evidence.
### [TEST-3] iop Makefile Target and Evidence Command
문제: [Makefile](/config/workspace/iop2oto/Makefile:77)는 generic `test-e2e`만 있고 OTO online smoke target이 없다.
해결 방법: `.PHONY``test-oto-registration-online-smoke`를 추가하고 target은 `cd ../oto && IOP_REPO_ROOT=$(CURDIR) dart test test/oto_iop_connection_smoke_test.dart`를 실행한다. 필요하면 `scripts/e2e-oto-registration-online-smoke.sh`를 새로 만들되, generic `scripts/e2e-smoke.sh`에 섞지 않는다.
수정 파일 및 체크리스트:
- [ ] `Makefile` `.PHONY`에 target 추가.
- [ ] `Makefile` target 추가 또는 새 `scripts/e2e-oto-registration-online-smoke.sh` 추가.
- [ ] output에 unlock handoff용 command line을 echo한다.
테스트 작성: Makefile target 자체가 smoke test다. 별도 unit test는 skip.
중간 검증:
```bash
make test-oto-registration-online-smoke
```
예상 결과: exit code 0.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `../oto/test/oto_iop_connection_smoke_test.dart` | TEST-1, TEST-2 |
| `Makefile` | TEST-3 |
| `scripts/e2e-oto-registration-online-smoke.sh` | TEST-3, only if wrapper script is cleaner than inline Makefile command |
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/transport ./apps/edge/internal/node ./apps/edge/internal/service ./packages/go/events
cd ../oto && dart analyze
make test-oto-registration-online-smoke
```
예상 결과: 모두 exit code 0. Smoke output must include the command used, registration accepted evidence, and first-heartbeat/online evidence. Go test cache output은 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -12,8 +12,12 @@ import (
"iop/packages/go/config" "iop/packages/go/config"
) )
// LifecycleConnected is the default lifecycle state for a freshly registered entry. const (
const LifecycleConnected = "connected" LifecycleAccepted = "accepted"
LifecycleOnline = "online"
LifecycleFailed = "failed"
LifecycleConnected = "connected"
)
// NodeEntry represents one connected node. // NodeEntry represents one connected node.
type NodeEntry struct { type NodeEntry struct {
@ -74,6 +78,17 @@ func (r *Registry) Register(entry *NodeEntry) {
} }
} }
func (r *Registry) UpdateLifecycle(nodeID string, state string) bool {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.byID[nodeID]
if !ok {
return false
}
entry.LifecycleState = state
return true
}
func (e *NodeEntry) DisplayLabel() string { func (e *NodeEntry) DisplayLabel() string {
if e == nil { if e == nil {
return "unknown" return "unknown"

View file

@ -126,3 +126,26 @@ func TestRegistry_UnregisterRemovesAlias(t *testing.T) {
t.Error("alias should be removed after unregister") t.Error("alias should be removed after unregister")
} }
} }
func TestRegistryUpdateLifecycle(t *testing.T) {
reg := edgenode.NewRegistry()
if ok := reg.UpdateLifecycle("non-existent", edgenode.LifecycleOnline); ok {
t.Error("expected false for updating non-existent node")
}
entry := &edgenode.NodeEntry{NodeID: "node-1", Alias: "alias-1"}
reg.Register(entry)
if ok := reg.UpdateLifecycle("node-1", edgenode.LifecycleOnline); !ok {
t.Error("expected true for updating existing node")
}
retrieved, ok := reg.Get("node-1")
if !ok {
t.Fatal("failed to get node")
}
if retrieved.LifecycleState != edgenode.LifecycleOnline {
t.Errorf("lifecycle: got %q, want %q", retrieved.LifecycleState, edgenode.LifecycleOnline)
}
}

View file

@ -11,6 +11,7 @@ import (
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
toki "git.toki-labs.com/toki/proto-socket/go" toki "git.toki-labs.com/toki/proto-socket/go"
"git.toki-labs.com/toki/proto-socket/go/packets"
edgenode "iop/apps/edge/internal/node" edgenode "iop/apps/edge/internal/node"
"iop/apps/edge/internal/transport" "iop/apps/edge/internal/transport"
@ -114,8 +115,8 @@ func TestEdgeServerOTORegistrationKind(t *testing.T) {
if entry.AgentKind != config.AgentKindOTOAgent { if entry.AgentKind != config.AgentKindOTOAgent {
t.Fatalf("agent kind: got %q want %q", entry.AgentKind, config.AgentKindOTOAgent) t.Fatalf("agent kind: got %q want %q", entry.AgentKind, config.AgentKindOTOAgent)
} }
if entry.LifecycleState != edgenode.LifecycleConnected { if entry.LifecycleState != edgenode.LifecycleAccepted {
t.Fatalf("lifecycle: got %q want %q", entry.LifecycleState, edgenode.LifecycleConnected) t.Fatalf("lifecycle: got %q want %q", entry.LifecycleState, edgenode.LifecycleAccepted)
} }
} }
@ -331,3 +332,291 @@ func TestEdgeServerIntegration(t *testing.T) {
t.Fatal("timeout waiting for node disconnected event") t.Fatal("timeout waiting for node disconnected event")
} }
} }
func TestEdgeServerOTOFirstHeartbeatMarksOnline(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
logger := zap.NewNop()
listenAddr := getFreePort(t)
registry := edgenode.NewRegistry()
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
{
ID: "oto-hb-01",
Alias: "oto-hb-node",
Token: "oto-hb-token",
AgentKind: config.AgentKindOTOAgent,
},
})
if err != nil {
t.Fatalf("load node store: %v", err)
}
server, err := transport.NewServer(listenAddr, registry, nodeStore, logger)
if err != nil {
t.Fatalf("new server: %v", err)
}
nodeEventCh := make(chan *iop.EdgeNodeEvent, 10)
server.SetNodeEventHandler(func(event *iop.EdgeNodeEvent) {
nodeEventCh <- event
})
if err := server.Start(ctx); err != nil {
t.Fatalf("start server: %v", err)
}
defer server.Stop()
client := dialNode(t, ctx, listenAddr)
defer client.Close()
resp, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator,
&iop.RegisterRequest{Token: "oto-hb-token"},
2*time.Second,
)
if err != nil {
t.Fatalf("register request: %v", err)
}
if !resp.GetAccepted() {
t.Fatalf("expected accepted, got reason %q", resp.GetReason())
}
select {
case event := <-nodeEventCh:
if event.GetType() != eventpkg.TypeNodeConnected {
t.Fatalf("expected connected event, got %q", event.GetType())
}
if event.GetMetadata()[eventpkg.MetadataLifecycleState] != edgenode.LifecycleAccepted {
t.Fatalf("expected lifecycle accepted, got %q", event.GetMetadata()[eventpkg.MetadataLifecycleState])
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for node connected event")
}
entry, ok := waitForRegistryEntry(ctx, registry, "oto-hb-01")
if !ok || entry.LifecycleState != edgenode.LifecycleAccepted {
t.Fatalf("expected registry state accepted, ok: %v, state: %q", ok, entry.LifecycleState)
}
err = client.Send(&packets.HeartBeat{})
if err != nil {
t.Fatalf("send heartbeat: %v", err)
}
select {
case event := <-nodeEventCh:
if event.GetType() != eventpkg.TypeNodeOnline {
t.Fatalf("expected online event, got %q", event.GetType())
}
if event.GetReason() != eventpkg.ReasonFirstHeartbeat {
t.Fatalf("expected reason first_heartbeat, got %q", event.GetReason())
}
if event.GetMetadata()[eventpkg.MetadataLifecycleState] != edgenode.LifecycleOnline {
t.Fatalf("expected lifecycle online, got %q", event.GetMetadata()[eventpkg.MetadataLifecycleState])
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for node online event")
}
if entry.LifecycleState != edgenode.LifecycleOnline {
t.Fatalf("expected registry state online, got %q", entry.LifecycleState)
}
}
func TestEdgeServerRegistrationFailureReasons(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
logger := zap.NewNop()
listenAddr := getFreePort(t)
registry := edgenode.NewRegistry()
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
{
ID: "node-fail-01",
Alias: "fail-node",
Token: "valid-token",
AgentKind: config.AgentKindGenericNode,
},
})
if err != nil {
t.Fatalf("load node store: %v", err)
}
server, err := transport.NewServer(listenAddr, registry, nodeStore, logger)
if err != nil {
t.Fatalf("new server: %v", err)
}
nodeEventCh := make(chan *iop.EdgeNodeEvent, 10)
server.SetNodeEventHandler(func(event *iop.EdgeNodeEvent) {
nodeEventCh <- event
})
if err := server.Start(ctx); err != nil {
t.Fatalf("start server: %v", err)
}
defer server.Stop()
// 1. Unknown Token Test
client1 := dialNode(t, ctx, listenAddr)
resp1, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client1.Communicator,
&iop.RegisterRequest{Token: "unknown-token-12345"},
2*time.Second,
)
if err != nil {
t.Fatalf("send register 1: %v", err)
}
if resp1.GetAccepted() {
t.Fatal("expected rejected")
}
select {
case event := <-nodeEventCh:
if event.GetType() != eventpkg.TypeNodeRegistrationFailed {
t.Fatalf("expected registration failed type, got %q", event.GetType())
}
if event.GetReason() != eventpkg.ReasonUnknownToken {
t.Fatalf("expected reason unknown_token, got %q", event.GetReason())
}
if event.GetMetadata()[eventpkg.MetadataFailureReason] != eventpkg.ReasonUnknownToken {
t.Fatalf("expected metadata failure reason, got %q", event.GetMetadata()[eventpkg.MetadataFailureReason])
}
if event.GetMetadata()[eventpkg.MetadataTokenPrefix] != "unknown-..." {
t.Fatalf("expected token prefix unknown-..., got %q", event.GetMetadata()[eventpkg.MetadataTokenPrefix])
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for unknown token registration failed event")
}
client1.Close()
// 2. Duplicate Connection Test
client2 := dialNode(t, ctx, listenAddr)
resp2, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client2.Communicator,
&iop.RegisterRequest{Token: "valid-token"},
2*time.Second,
)
if err != nil {
t.Fatalf("send register 2: %v", err)
}
if !resp2.GetAccepted() {
t.Fatalf("expected register 2 accepted, got %q", resp2.GetReason())
}
// Consume connection event
<-nodeEventCh
client3 := dialNode(t, ctx, listenAddr)
resp3, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client3.Communicator,
&iop.RegisterRequest{Token: "valid-token"},
2*time.Second,
)
if err != nil {
t.Fatalf("send register 3: %v", err)
}
if resp3.GetAccepted() {
t.Fatal("expected duplicate connection registration rejected")
}
select {
case event := <-nodeEventCh:
if event.GetType() != eventpkg.TypeNodeRegistrationFailed {
t.Fatalf("expected registration failed type, got %q", event.GetType())
}
if event.GetReason() != eventpkg.ReasonDuplicateConnection {
t.Fatalf("expected reason duplicate_connection, got %q", event.GetReason())
}
if event.GetMetadata()[eventpkg.MetadataFailureReason] != eventpkg.ReasonDuplicateConnection {
t.Fatalf("expected failure reason metadata, got %q", event.GetMetadata()[eventpkg.MetadataFailureReason])
}
if event.GetMetadata()[eventpkg.MetadataAgentKind] != config.AgentKindGenericNode {
t.Fatalf("expected agent kind metadata, got %q", event.GetMetadata()[eventpkg.MetadataAgentKind])
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for duplicate registration failed event")
}
client2.Close()
client3.Close()
}
func TestEdgeServerHeartbeatTimeoutDisconnect(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
logger := zap.NewNop()
listenAddr := getFreePort(t)
registry := edgenode.NewRegistry()
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
{
ID: "node-timeout-01",
Alias: "timeout-node",
Token: "timeout-token",
AgentKind: config.AgentKindOTOAgent,
},
})
if err != nil {
t.Fatalf("load node store: %v", err)
}
server, err := transport.NewServer(listenAddr, registry, nodeStore, logger)
if err != nil {
t.Fatalf("new server: %v", err)
}
server.HeartbeatInterval = 1
server.HeartbeatWait = 1
nodeEventCh := make(chan *iop.EdgeNodeEvent, 10)
server.SetNodeEventHandler(func(event *iop.EdgeNodeEvent) {
nodeEventCh <- event
})
if err := server.Start(ctx); err != nil {
t.Fatalf("start server: %v", err)
}
defer server.Stop()
client := dialNode(t, ctx, listenAddr)
defer client.Close()
client.RemoveListeners(toki.TypeNameOf(&packets.HeartBeat{}))
resp, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator,
&iop.RegisterRequest{Token: "timeout-token"},
2*time.Second,
)
if err != nil {
t.Fatalf("register request: %v", err)
}
if !resp.GetAccepted() {
t.Fatalf("expected accepted, got %q", resp.GetReason())
}
select {
case event := <-nodeEventCh:
if event.GetType() != eventpkg.TypeNodeConnected {
t.Fatalf("expected connected event, got %q", event.GetType())
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for connected event")
}
select {
case event := <-nodeEventCh:
if event.GetType() != eventpkg.TypeNodeDisconnected {
t.Fatalf("expected disconnected event, got %q", event.GetType())
}
if event.GetReason() != eventpkg.ReasonHeartbeatTimeout {
t.Fatalf("expected reason heartbeat_timeout, got %q", event.GetReason())
}
if event.GetMetadata()[eventpkg.MetadataFailureReason] != eventpkg.ReasonHeartbeatTimeout {
t.Fatalf("expected failure reason heartbeat_timeout, got %q", event.GetMetadata()[eventpkg.MetadataFailureReason])
}
if event.GetMetadata()[eventpkg.MetadataLifecycleState] != edgenode.LifecycleFailed {
t.Fatalf("expected lifecycle state failed, got %q", event.GetMetadata()[eventpkg.MetadataLifecycleState])
}
case <-time.After(3 * time.Second):
t.Fatal("timeout waiting for heartbeat timeout disconnected event")
}
}

View file

@ -8,6 +8,7 @@ import (
"sync/atomic" "sync/atomic"
toki "git.toki-labs.com/toki/proto-socket/go" toki "git.toki-labs.com/toki/proto-socket/go"
"git.toki-labs.com/toki/proto-socket/go/packets"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@ -47,15 +48,17 @@ func edgeParserMap() toki.ParserMap {
// Server wraps proto-socket TcpServer and manages node connections. // Server wraps proto-socket TcpServer and manages node connections.
type Server struct { type Server struct {
tcp *toki.TcpServer tcp *toki.TcpServer
listen string listen string
registry *edgenode.Registry registry *edgenode.Registry
nodeStore *edgenode.NodeStore nodeStore *edgenode.NodeStore
logger *zap.Logger logger *zap.Logger
handlerMu sync.RWMutex handlerMu sync.RWMutex
onRunEvent func(*iop.RunEvent) onRunEvent func(*iop.RunEvent)
onNodeEvent func(*iop.EdgeNodeEvent) onNodeEvent func(*iop.EdgeNodeEvent)
stopping atomic.Bool stopping atomic.Bool
HeartbeatInterval int
HeartbeatWait int
} }
func NewServer(listen string, registry *edgenode.Registry, nodeStore *edgenode.NodeStore, logger *zap.Logger) (*Server, error) { func NewServer(listen string, registry *edgenode.Registry, nodeStore *edgenode.NodeStore, logger *zap.Logger) (*Server, error) {
@ -68,9 +71,16 @@ func NewServer(listen string, registry *edgenode.Registry, nodeStore *edgenode.N
return nil, err return nil, err
} }
s := &Server{listen: listen, registry: registry, nodeStore: nodeStore, logger: logger} s := &Server{
listen: listen,
registry: registry,
nodeStore: nodeStore,
logger: logger,
HeartbeatInterval: heartbeatIntervalSec,
HeartbeatWait: heartbeatWaitSec,
}
s.tcp = toki.NewTcpServer(host, port, func(conn net.Conn) *toki.TcpClient { s.tcp = toki.NewTcpServer(host, port, func(conn net.Conn) *toki.TcpClient {
return toki.NewTcpClient(conn, heartbeatIntervalSec, heartbeatWaitSec, edgeParserMap()) return toki.NewTcpClient(conn, s.HeartbeatInterval, s.HeartbeatWait, edgeParserMap())
}) })
s.tcp.OnClientConnected = s.onNodeConnected s.tcp.OnClientConnected = s.onNodeConnected
return s, nil return s, nil
@ -137,6 +147,17 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) {
rec, ok := s.nodeStore.FindByToken(req.GetToken()) rec, ok := s.nodeStore.FindByToken(req.GetToken())
if !ok { if !ok {
s.logger.Warn("unknown token", zap.String("token_prefix", safePrefix(req.GetToken()))) s.logger.Warn("unknown token", zap.String("token_prefix", safePrefix(req.GetToken())))
s.emitNodeEvent(events.NewEdgeNodeEvent(
events.SourceEdge,
events.TypeNodeRegistrationFailed,
"",
"",
events.ReasonUnknownToken,
map[string]string{
events.MetadataFailureReason: events.ReasonUnknownToken,
events.MetadataTokenPrefix: safePrefix(req.GetToken()),
},
))
return &iop.RegisterResponse{Accepted: false, Reason: "unknown token"}, nil return &iop.RegisterResponse{Accepted: false, Reason: "unknown token"}, nil
} }
@ -149,6 +170,17 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) {
zap.String("node_id", rec.ID), zap.String("node_id", rec.ID),
zap.String("agent_kind", rec.AgentKind), zap.String("agent_kind", rec.AgentKind),
) )
s.emitNodeEvent(events.NewEdgeNodeEvent(
events.SourceEdge,
events.TypeNodeRegistrationFailed,
rec.ID,
rec.Alias,
events.ReasonDuplicateConnection,
map[string]string{
events.MetadataFailureReason: events.ReasonDuplicateConnection,
events.MetadataAgentKind: rec.AgentKind,
},
))
return &iop.RegisterResponse{Accepted: false, Reason: reason}, nil return &iop.RegisterResponse{Accepted: false, Reason: reason}, nil
} }
@ -161,13 +193,18 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) {
return &iop.RegisterResponse{Accepted: false, Reason: "internal config error"}, nil return &iop.RegisterResponse{Accepted: false, Reason: "internal config error"}, nil
} }
lifecycle := edgenode.LifecycleConnected
if rec.AgentKind == config.AgentKindOTOAgent {
lifecycle = edgenode.LifecycleAccepted
}
entry := &edgenode.NodeEntry{ entry := &edgenode.NodeEntry{
NodeID: rec.ID, NodeID: rec.ID,
Alias: rec.Alias, Alias: rec.Alias,
AgentKind: rec.AgentKind, AgentKind: rec.AgentKind,
Client: client, LifecycleState: lifecycle,
Index: rec.Index, Client: client,
HasIndex: true, Index: rec.Index,
HasIndex: true,
} }
toki.AddListenerTyped[*iop.EdgeNodeEvent](&client.Communicator, func(e *iop.EdgeNodeEvent) { toki.AddListenerTyped[*iop.EdgeNodeEvent](&client.Communicator, func(e *iop.EdgeNodeEvent) {
if e.NodeId == "" { if e.NodeId == "" {
@ -178,22 +215,58 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) {
} }
s.emitNodeEvent(e) s.emitNodeEvent(e)
}) })
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,
map[string]string{
events.MetadataAgentKind: rec.AgentKind,
events.MetadataLifecycleState: edgenode.LifecycleOnline,
},
))
}
})
})
}
client.AddDisconnectListener(func(_ *toki.TcpClient) { client.AddDisconnectListener(func(_ *toki.TcpClient) {
transportInfo := client.DisconnectInfo() transportInfo := client.DisconnectInfo()
fields := []zap.Field{zap.String("node_id", rec.ID)} fields := []zap.Field{zap.String("node_id", rec.ID)}
fields = append(fields, transportDisconnectFields(transportInfo)...) fields = append(fields, transportDisconnectFields(transportInfo)...)
s.logger.Info("node unregistered", fields...) s.logger.Info("node unregistered", fields...)
reason := events.ReasonTransportClosed reason := events.ReasonTransportClosed
if s.stopping.Load() { if transportInfo.Reason == "heartbeat_timeout" {
reason = events.ReasonHeartbeatTimeout
s.registry.UpdateLifecycle(rec.ID, edgenode.LifecycleFailed)
} else if s.stopping.Load() {
reason = events.ReasonEdgeShutdown reason = events.ReasonEdgeShutdown
} }
meta := transportDisconnectMetadata(transportInfo)
if meta == nil {
meta = make(map[string]string)
}
if reason == events.ReasonHeartbeatTimeout {
meta[events.MetadataFailureReason] = events.ReasonHeartbeatTimeout
meta[events.MetadataLifecycleState] = edgenode.LifecycleFailed
} else {
meta[events.MetadataLifecycleState] = edgenode.LifecycleFailed
}
meta[events.MetadataAgentKind] = rec.AgentKind
s.emitNodeEvent(events.NewEdgeNodeEvent( s.emitNodeEvent(events.NewEdgeNodeEvent(
events.SourceEdge, events.SourceEdge,
events.TypeNodeDisconnected, events.TypeNodeDisconnected,
rec.ID, rec.ID,
rec.Alias, rec.Alias,
reason, reason,
transportDisconnectMetadata(transportInfo), meta,
)) ))
s.registry.Unregister(rec.ID) s.registry.Unregister(rec.ID)
}) })
@ -208,7 +281,10 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) {
rec.ID, rec.ID,
rec.Alias, rec.Alias,
events.ReasonRegistered, events.ReasonRegistered,
nil, map[string]string{
events.MetadataAgentKind: rec.AgentKind,
events.MetadataLifecycleState: lifecycle,
},
)) ))
return &iop.RegisterResponse{ return &iop.RegisterResponse{

View file

@ -12,17 +12,27 @@ const (
SourceEdge = "edge" SourceEdge = "edge"
SourceNode = "node" SourceNode = "node"
TypeNodeConnected = "node.connected" TypeNodeConnected = "node.connected"
TypeNodeDisconnected = "node.disconnected" TypeNodeDisconnected = "node.disconnected"
TypeEdgeDisconnected = "edge.disconnected" TypeEdgeDisconnected = "edge.disconnected"
TypeNodeOnline = "node.online"
TypeNodeRegistrationFailed = "node.registration_failed"
ReasonRegistered = "registered" ReasonRegistered = "registered"
ReasonTransportClosed = "transport_closed" ReasonTransportClosed = "transport_closed"
ReasonLocalShutdown = "local_shutdown" ReasonLocalShutdown = "local_shutdown"
ReasonEdgeShutdown = "edge_shutdown" ReasonEdgeShutdown = "edge_shutdown"
ReasonFirstHeartbeat = "first_heartbeat"
ReasonUnknownToken = "unknown_token"
ReasonDuplicateConnection = "duplicate_connection"
ReasonHeartbeatTimeout = "heartbeat_timeout"
MetadataTransportCloseReason = "transport_close_reason" MetadataTransportCloseReason = "transport_close_reason"
MetadataTransportCloseError = "transport_close_error" MetadataTransportCloseError = "transport_close_error"
MetadataAgentKind = "agent_kind"
MetadataLifecycleState = "lifecycle_state"
MetadataFailureReason = "failure_reason"
MetadataTokenPrefix = "token_prefix"
) )
func NewEdgeNodeEvent(source, eventType, nodeID, alias, reason string, metadata map[string]string) *iop.EdgeNodeEvent { func NewEdgeNodeEvent(source, eventType, nodeID, alias, reason string, metadata map[string]string) *iop.EdgeNodeEvent {