diff --git a/agent-roadmap/phase/automation-runtime-bridge/milestones/oto-registration-online-smoke.md b/agent-roadmap/phase/automation-runtime-bridge/milestones/oto-registration-online-smoke.md index 2d5cb47..0a95fd7 100644 --- a/agent-roadmap/phase/automation-runtime-bridge/milestones/oto-registration-online-smoke.md +++ b/agent-roadmap/phase/automation-runtime-bridge/milestones/oto-registration-online-smoke.md @@ -61,4 +61,5 @@ Edge와 OTO가 같은 기준으로 online/failed 상태를 판정한다. - 표준선(선택): heartbeat timeout은 transport close reason과 lifecycle event metadata로 남긴다. - 선행 작업: OTO Agent Registry 구현, OTO Bootstrap Command 발급 - 후속 작업: 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 기준을 작성했다. diff --git a/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/code_review_cloud_G07_0.log new file mode 100644 index 0000000..392cb2d --- /dev/null +++ b/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/code_review_cloud_G07_0.log @@ -0,0 +1,171 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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`를 작성한다. diff --git a/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/code_review_local_G01_1.log b/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/code_review_local_G01_1.log new file mode 100644 index 0000000..3f0590e --- /dev/null +++ b/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/code_review_local_G01_1.log @@ -0,0 +1,133 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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로 이동한다. diff --git a/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/complete.log b/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/complete.log new file mode 100644 index 0000000..3434fa7 --- /dev/null +++ b/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/plan_cloud_G07_0.log b/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/plan_cloud_G07_0.log new file mode 100644 index 0000000..5cf66c9 --- /dev/null +++ b/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/plan_cloud_G07_0.log @@ -0,0 +1,289 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/plan_local_G01_1.log b/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/plan_local_G01_1.log new file mode 100644 index 0000000..4f9792a --- /dev/null +++ b/agent-task/archive/2026/06/m-oto-registration-online-smoke/01_iop_edge_lifecycle/plan_local_G01_1.log @@ -0,0 +1,58 @@ + + +# 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. diff --git a/agent-task/m-oto-registration-online-smoke/02+01_oto_agent_session/CODE_REVIEW-cloud-G06.md b/agent-task/m-oto-registration-online-smoke/02+01_oto_agent_session/CODE_REVIEW-cloud-G06.md new file mode 100644 index 0000000..063d696 --- /dev/null +++ b/agent-task/m-oto-registration-online-smoke/02+01_oto_agent_session/CODE_REVIEW-cloud-G06.md @@ -0,0 +1,110 @@ + + +# 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?** diff --git a/agent-task/m-oto-registration-online-smoke/02+01_oto_agent_session/PLAN-cloud-G06.md b/agent-task/m-oto-registration-online-smoke/02+01_oto_agent_session/PLAN-cloud-G06.md new file mode 100644 index 0000000..964f85b --- /dev/null +++ b/agent-task/m-oto-registration-online-smoke/02+01_oto_agent_session/PLAN-cloud-G06.md @@ -0,0 +1,198 @@ + + +# 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 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()..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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-oto-registration-online-smoke/03+01,02_smoke_evidence/CODE_REVIEW-cloud-G07.md b/agent-task/m-oto-registration-online-smoke/03+01,02_smoke_evidence/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..8830d05 --- /dev/null +++ b/agent-task/m-oto-registration-online-smoke/03+01,02_smoke_evidence/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,117 @@ + + +# 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?** diff --git a/agent-task/m-oto-registration-online-smoke/03+01,02_smoke_evidence/PLAN-cloud-G07.md b/agent-task/m-oto-registration-online-smoke/03+01,02_smoke_evidence/PLAN-cloud-G07.md new file mode 100644 index 0000000..f7a713b --- /dev/null +++ b/agent-task/m-oto-registration-online-smoke/03+01,02_smoke_evidence/PLAN-cloud-G07.md @@ -0,0 +1,184 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/internal/node/registry.go b/apps/edge/internal/node/registry.go index 7d97ead..ed27f68 100644 --- a/apps/edge/internal/node/registry.go +++ b/apps/edge/internal/node/registry.go @@ -12,8 +12,12 @@ import ( "iop/packages/go/config" ) -// LifecycleConnected is the default lifecycle state for a freshly registered entry. -const LifecycleConnected = "connected" +const ( + LifecycleAccepted = "accepted" + LifecycleOnline = "online" + LifecycleFailed = "failed" + LifecycleConnected = "connected" +) // NodeEntry represents one connected node. 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 { if e == nil { return "unknown" diff --git a/apps/edge/internal/node/registry_test.go b/apps/edge/internal/node/registry_test.go index dd112fc..d90742a 100644 --- a/apps/edge/internal/node/registry_test.go +++ b/apps/edge/internal/node/registry_test.go @@ -126,3 +126,26 @@ func TestRegistry_UnregisterRemovesAlias(t *testing.T) { 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) + } +} diff --git a/apps/edge/internal/transport/integration_test.go b/apps/edge/internal/transport/integration_test.go index 4d3510c..513d63c 100644 --- a/apps/edge/internal/transport/integration_test.go +++ b/apps/edge/internal/transport/integration_test.go @@ -11,6 +11,7 @@ import ( "google.golang.org/protobuf/proto" toki "git.toki-labs.com/toki/proto-socket/go" + "git.toki-labs.com/toki/proto-socket/go/packets" edgenode "iop/apps/edge/internal/node" "iop/apps/edge/internal/transport" @@ -114,8 +115,8 @@ func TestEdgeServerOTORegistrationKind(t *testing.T) { if entry.AgentKind != config.AgentKindOTOAgent { t.Fatalf("agent kind: got %q want %q", entry.AgentKind, config.AgentKindOTOAgent) } - if entry.LifecycleState != edgenode.LifecycleConnected { - t.Fatalf("lifecycle: got %q want %q", entry.LifecycleState, edgenode.LifecycleConnected) + if entry.LifecycleState != edgenode.LifecycleAccepted { + 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") } } + +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") + } +} diff --git a/apps/edge/internal/transport/server.go b/apps/edge/internal/transport/server.go index 1af6cd1..e0ed19b 100644 --- a/apps/edge/internal/transport/server.go +++ b/apps/edge/internal/transport/server.go @@ -8,6 +8,7 @@ import ( "sync/atomic" toki "git.toki-labs.com/toki/proto-socket/go" + "git.toki-labs.com/toki/proto-socket/go/packets" "go.uber.org/zap" "google.golang.org/protobuf/proto" @@ -47,15 +48,17 @@ func edgeParserMap() toki.ParserMap { // Server wraps proto-socket TcpServer and manages node connections. type Server struct { - tcp *toki.TcpServer - listen string - registry *edgenode.Registry - nodeStore *edgenode.NodeStore - logger *zap.Logger - handlerMu sync.RWMutex - onRunEvent func(*iop.RunEvent) - onNodeEvent func(*iop.EdgeNodeEvent) - stopping atomic.Bool + tcp *toki.TcpServer + listen string + registry *edgenode.Registry + nodeStore *edgenode.NodeStore + logger *zap.Logger + handlerMu sync.RWMutex + onRunEvent func(*iop.RunEvent) + onNodeEvent func(*iop.EdgeNodeEvent) + stopping atomic.Bool + HeartbeatInterval int + HeartbeatWait int } 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 } - 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 { - return toki.NewTcpClient(conn, heartbeatIntervalSec, heartbeatWaitSec, edgeParserMap()) + return toki.NewTcpClient(conn, s.HeartbeatInterval, s.HeartbeatWait, edgeParserMap()) }) s.tcp.OnClientConnected = s.onNodeConnected return s, nil @@ -137,6 +147,17 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) { rec, ok := s.nodeStore.FindByToken(req.GetToken()) if !ok { 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 } @@ -149,6 +170,17 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) { zap.String("node_id", rec.ID), 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 } @@ -161,13 +193,18 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) { return &iop.RegisterResponse{Accepted: false, Reason: "internal config error"}, nil } + lifecycle := edgenode.LifecycleConnected + if rec.AgentKind == config.AgentKindOTOAgent { + lifecycle = edgenode.LifecycleAccepted + } entry := &edgenode.NodeEntry{ - NodeID: rec.ID, - Alias: rec.Alias, - AgentKind: rec.AgentKind, - Client: client, - Index: rec.Index, - HasIndex: true, + NodeID: rec.ID, + Alias: rec.Alias, + AgentKind: rec.AgentKind, + LifecycleState: lifecycle, + Client: client, + Index: rec.Index, + HasIndex: true, } toki.AddListenerTyped[*iop.EdgeNodeEvent](&client.Communicator, func(e *iop.EdgeNodeEvent) { if e.NodeId == "" { @@ -178,22 +215,58 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) { } 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) { transportInfo := client.DisconnectInfo() fields := []zap.Field{zap.String("node_id", rec.ID)} fields = append(fields, transportDisconnectFields(transportInfo)...) s.logger.Info("node unregistered", fields...) 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 } + + 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( events.SourceEdge, events.TypeNodeDisconnected, rec.ID, rec.Alias, reason, - transportDisconnectMetadata(transportInfo), + meta, )) s.registry.Unregister(rec.ID) }) @@ -208,7 +281,10 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) { rec.ID, rec.Alias, events.ReasonRegistered, - nil, + map[string]string{ + events.MetadataAgentKind: rec.AgentKind, + events.MetadataLifecycleState: lifecycle, + }, )) return &iop.RegisterResponse{ diff --git a/packages/go/events/events.go b/packages/go/events/events.go index 3208367..089bcdf 100644 --- a/packages/go/events/events.go +++ b/packages/go/events/events.go @@ -12,17 +12,27 @@ const ( SourceEdge = "edge" SourceNode = "node" - TypeNodeConnected = "node.connected" - TypeNodeDisconnected = "node.disconnected" - TypeEdgeDisconnected = "edge.disconnected" + TypeNodeConnected = "node.connected" + TypeNodeDisconnected = "node.disconnected" + TypeEdgeDisconnected = "edge.disconnected" + TypeNodeOnline = "node.online" + TypeNodeRegistrationFailed = "node.registration_failed" - ReasonRegistered = "registered" - ReasonTransportClosed = "transport_closed" - ReasonLocalShutdown = "local_shutdown" - ReasonEdgeShutdown = "edge_shutdown" + ReasonRegistered = "registered" + ReasonTransportClosed = "transport_closed" + ReasonLocalShutdown = "local_shutdown" + ReasonEdgeShutdown = "edge_shutdown" + ReasonFirstHeartbeat = "first_heartbeat" + ReasonUnknownToken = "unknown_token" + ReasonDuplicateConnection = "duplicate_connection" + ReasonHeartbeatTimeout = "heartbeat_timeout" MetadataTransportCloseReason = "transport_close_reason" 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 {