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:
parent
482c4f198a
commit
b8ad51f4b8
15 changed files with 1751 additions and 32 deletions
|
|
@ -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 기준을 작성했다.
|
||||||
|
|
|
||||||
|
|
@ -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`를 작성한다.
|
||||||
|
|
@ -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로 이동한다.
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
## 후속 작업
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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?**
|
||||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||||
|
|
@ -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?**
|
||||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue