feat(edge): runtime config refresh mechanism implementation

- Add configrefresh package for config classification, request handling
- Add bootstrap refresh_admin.go for admin-level config refresh
- Add edgevalidate package for config validation
- Update edge bootstrap runtime with refresh capabilities
- Update edge service layer with config refresh integration
- Update openai layer routes/chat_handler/server for config refresh
- Update edgecmd config handling with refresh support
- Update input manager, transport server, status provider
- Add edge.yaml config updates and go/config package changes
- Add hostsetup templates and test updates
- Update roadmap and phase documentation for runtime-reconnect-config-refresh
This commit is contained in:
toki 2026-06-21 21:35:50 +09:00
parent ff161b9b12
commit 445716cc3e
39 changed files with 4720 additions and 128 deletions

View file

@ -51,14 +51,14 @@ provider/device/model별 qualification report와 모델 lifecycle 관리는 prov
- 경로: `agent-roadmap/phase/operational-observability-provider-management/PHASE.md` - 경로: `agent-roadmap/phase/operational-observability-provider-management/PHASE.md`
- 요약: 사용자/토큰/사용량/로그 추적과 API/CLI/local inference provider catalog, 로컬 디바이스 provider 상태 관리, provider/device/model qualification report와 모델 lifecycle 관리 방향을 MVP 운영 축과 후속 심화 축으로 스케치한다. - 요약: 사용자/토큰/사용량/로그 추적과 API/CLI/local inference provider catalog, 로컬 디바이스 provider 상태 관리, provider/device/model qualification report와 모델 lifecycle 관리 방향을 MVP 운영 축과 후속 심화 축으로 스케치한다.
- [진행중] Update Plane과 자체 업데이트 기반
- 경로: `agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md`
- 요약: frontend와 Control Plane만 재배포해도 Edge/Node가 안정 업데이트 프로토콜, 로컬 상태 캐시, host-local manager를 통해 스스로 버전 수렴하는 기반을 정리한다.
- [계획] Automation Runtime과 Bridge 확장 - [계획] Automation Runtime과 Bridge 확장
- 경로: `agent-roadmap/phase/automation-runtime-bridge/PHASE.md` - 경로: `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
- 요약: CLI Agent 실행과 운영 자동화의 MVP 표면을 정리하고, 사용량 limit 알림/자동 이어받기를 우선 스케치하되 원격 터널링과 oto scheduler/CI-CD는 2차로 잠근다. - 요약: CLI Agent 실행과 운영 자동화의 MVP 표면을 정리하고, 사용량 limit 알림/자동 이어받기를 우선 스케치하되 원격 터널링과 oto scheduler/CI-CD는 2차로 잠근다.
- [계획] Update Plane과 자체 업데이트 기반
- 경로: `agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md`
- 요약: frontend와 Control Plane만 재배포해도 Edge/Node가 안정 업데이트 프로토콜, 로컬 상태 캐시, host-local manager를 통해 스스로 버전 수렴하는 기반을 정리한다.
- [계획] 지식과 도구 최적화 확장 - [계획] 지식과 도구 최적화 확장
- 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md` - 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md`
- 요약: 로컬 모델 성능 향상 축으로 단계 호출, tool/schema 강제, 검증/retry/fallback의 MVP 실행 모드를 먼저 스케치하고, RAG 장기 기억, advisor, context hook은 2차로 분리한다. - 요약: 로컬 모델 성능 향상 축으로 단계 호출, tool/schema 강제, 검증/retry/fallback의 MVP 실행 모드를 먼저 스케치하고, RAG 장기 기억, advisor, context hook은 2차로 분리한다.

View file

@ -2,7 +2,7 @@
## 상태 ## 상태
[계획] [진행중]
## 목표 ## 목표
@ -16,7 +16,7 @@ Control Plane은 release manifest, desired version, rollout/audit view를 제공
완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다. 완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
스케치 Milestone은 아직 구현 가능한 계획이 아니므로 사용자 검토와 구체화 후 `[계획]`으로 승격한다. 스케치 Milestone은 아직 구현 가능한 계획이 아니므로 사용자 검토와 구체화 후 `[계획]`으로 승격한다.
- [계획] Edge/Node Runtime Reconnect와 Config Refresh - [진행중] Edge/Node Runtime Reconnect와 Config Refresh
- 경로: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md` - 경로: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- 요약: Edge 단절 후 Node 10초 간격 10회 재접속과 종료 정책, 운영 중 config refresh/diff/apply/Node 전파의 MVP 경계를 구현 가능한 계획으로 정리한다. - 요약: Edge 단절 후 Node 10초 간격 10회 재접속과 종료 정책, 운영 중 config refresh/diff/apply/Node 전파의 MVP 경계를 구현 가능한 계획으로 정리한다.

View file

@ -12,7 +12,7 @@ Edge 재시작이나 일시 단절 뒤에도 Node가 지정된 retry 정책으
## 상태 ## 상태
[계획] [진행중]
## 승격 조건 ## 승격 조건
@ -48,10 +48,10 @@ Edge 재시작이나 일시 단절 뒤에도 Node가 지정된 retry 정책으
Node가 Edge 단절을 운영 가능한 transient failure로 처리하되, 무한 루프 없이 명시된 retry 한계에서 종료한다. Node가 Edge 단절을 운영 가능한 transient failure로 처리하되, 무한 루프 없이 명시된 retry 한계에서 종료한다.
- [ ] [retry-policy] Node reconnect 기본값이 `interval=10s`, `max_attempts=10`으로 고정되고 config/문서/로그에 노출된다. 검증: reconnect 단위 테스트에서 10초 정책을 fake clock 또는 injectable sleeper로 확인하고, 10회 초과 시 종료 경로를 확인한다. - [x] [retry-policy] Node reconnect 기본값이 `interval=10s`, `max_attempts=10`으로 고정되고 config/문서/로그에 노출된다. 검증: reconnect 단위 테스트에서 10초 정책을 fake clock 또는 injectable sleeper로 확인하고, 10회 초과 시 종료 경로를 확인한다.
- [ ] [supervisor] Node bootstrap lifecycle이 최초 connect 성공 이후 Edge disconnect를 감지하고 reconnect supervisor로 세션을 재수립한다. 검증: transport fake나 integration test에서 Edge stop/start 후 Node가 재등록된다. - [x] [supervisor] Node bootstrap lifecycle이 최초 connect 성공 이후 Edge disconnect를 감지하고 reconnect supervisor로 세션을 재수립한다. 검증: transport fake나 integration test에서 Edge stop/start 후 Node가 재등록된다.
- [ ] [exit-limit] 재접속 실패가 retry 한계를 넘으면 Node가 adapter/store/session을 정리하고 process 종료 신호를 반환한다. 검증: 실패 Edge endpoint로 10회 시도 후 non-zero 종료 또는 fx shutdown path가 관측된다. - [x] [exit-limit] 재접속 실패가 retry 한계를 넘으면 Node가 adapter/store/session을 정리하고 process 종료 신호를 반환한다. 검증: 실패 Edge endpoint로 10회 시도 후 non-zero 종료 또는 fx shutdown path가 관측된다.
- [ ] [dedupe] Edge registry는 이전 연결 disconnect 정리 뒤 같은 node id의 재등록을 정상 수용하고, 아직 살아 있는 duplicate connection은 계속 거부한다. 검증: duplicate active connection과 reconnect-after-unregister 케이스가 각각 테스트된다. - [x] [dedupe] Edge registry는 이전 연결 disconnect 정리 뒤 같은 node id의 재등록을 정상 수용하고, 아직 살아 있는 duplicate connection은 계속 거부한다. 검증: duplicate active connection과 reconnect-after-unregister 케이스가 각각 테스트된다.
### Epic: [refresh] Edge config refresh 계약 ### Epic: [refresh] Edge config refresh 계약
@ -83,12 +83,12 @@ Edge가 refresh된 NodeConfigPayload를 연결 Node에 전달하고, Node는 변
- 상태: 없음 - 상태: 없음
- 요청일: 없음 - 요청일: 없음
- 완료 근거: 구현 전 계획 상태이며 기능 Task는 아직 미완료다. - 완료 근거: `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/complete.log``Roadmap Completion`에 따라 `retry-policy`, `supervisor`, `exit-limit`가 PASS로 확인되었다. `dedupe``TestEdgeServerDuplicateRegistrationReason``TestEdgeServerReconnectAfterUnregisterAccepted` 존재 및 `go test -count=1 ./apps/edge/internal/transport` PASS로 확인되었다.
- 검토 항목: - 검토 항목:
- [x] SDD gate가 승인되어 구현 잠금이 해제되었다 - [x] SDD gate가 승인되어 구현 잠금이 해제되었다
- [ ] 모든 기능 Task와 Task 안의 검증이 충족되었다 - [ ] 모든 기능 Task와 Task 안의 검증이 충족되었다
- [ ] 사용자가 field dev 환경 결과를 확인했다 - [ ] 사용자가 field dev 환경 결과를 확인했다
- 리뷰 코멘트: 없음 - 리뷰 코멘트: refresh/node-config/field Task는 완료 근거가 없어 미완료로 유지한다.
## 범위 제외 ## 범위 제외

View file

@ -0,0 +1,265 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=0 tag=REFRESH_CONTRACT -->
# Code Review Reference - REFRESH_CONTRACT
> **[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 selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only Milestone lock decisions in `사용자 리뷰 요청` and stop for code-review.
> 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-21
task=m-runtime-reconnect-config-refresh/02_refresh_contract, plan=0, tag=REFRESH_CONTRACT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- 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-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFRESH_CONTRACT-1] Edge-local refresh admin API와 CLI entrypoint | [x] |
| [REFRESH_CONTRACT-2] config refresh diff classifier | [x] |
| [REFRESH_CONTRACT-3] final evidence and review stub completion | [x] |
## 구현 체크리스트
- [x] [REFRESH_CONTRACT-1] Edge-local refresh admin API와 `iop-edge config refresh` CLI를 추가해 running Edge process에 dry-run/apply 요청이 도달하게 한다.
- [x] [REFRESH_CONTRACT-2] candidate config load/validation/diff classifier를 추가하고 provider capacity는 `applied`, listen port는 `restart_required`, invalid YAML/provider config는 `rejected`로 테스트한다.
- [x] [REFRESH_CONTRACT-3] `entrypoint`와 `diff-classify` 대상 최종 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [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-runtime-reconnect-config-refresh/02_refresh_contract/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.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가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- **`configrefresh.Evaluate` 시그니처**: 계획의 After shape는 `(ctx, current, req) -> (Result, error)` 2-tuple이었으나, apply 모드에서 Runtime이 candidate config를 커밋하기 위해 `(Result, *config.EdgeConfig, error)` 3-tuple로 구현했다. `RefreshConfig` 메서드가 apply+applied 시 내부적으로 `applyMutableConfig`를 호출하므로 외부 HTTP/CLI 계약은 변경 없다.
- **CLI mode 정규화**: `--mode dry-run`(하이픈)을 `dry_run`(언더스코어) Mode 상수로 정규화하는 switch를 추가했다. 계획의 예시(`--mode dry-run`)와 내부 Mode 상수(`dry_run`) 불일치를 해소하기 위함이다.
- **테스트 픽스처**: `TestRefreshAdminDryRunAndApplyReachRunningRuntime`에서 in-memory config를 직접 빌드하면 viper 기본값(bootstrap.listen 등)과 candidate YAML의 기본값이 달라 restart_required 오탐이 발생했다. base config도 `config.LoadEdge`로 로드하고 explicit 필드를 YAML에 명시하는 방식으로 수정했다.
- **`validateEdgeConfig` 공유 정리**: 계획에서 "config check와 refresh가 같은 validator를 쓰게 정리"를 명시했으나, configrefresh는 `config.LoadEdge`만으로 S09 시나리오(YAML parse error, duplicate provider id) 커버가 충분해 `edgecmd.validateEdgeConfig`(adapter URL 비어있음 등 CLI 전용 체크)의 공유 통합은 REFRESH_APPLY plan으로 이관했다. plan 변경 사항에 기록한다.
## 주요 설계 결정
- **RefreshAdminServer는 Runtime 포인터를 직접 보유**: HTTP 핸들러가 `runtime.RefreshConfig(ctx, req)`를 호출하고 결과를 JSON으로 반환한다. fx lifecycle 바깥에서 port-0 테스트가 가능하도록 `Addr()` 메서드를 노출했다.
- **apply 모드의 mutable-only happy path**: `configrefresh.Evaluate`가 `StatusApplied` + 후보 config를 반환하면, `Runtime.applyMutableConfig`가 `cfgMu` write lock 아래서 `r.Cfg.Nodes[].Providers[]` 용량 필드와 `r.Cfg.Models`를 갱신하고 `r.Service.SetModelCatalog`를 호출한다. rollback 증명은 REFRESH_APPLY plan 범위다.
- **Refresh.enabled=false이면 admin server가 시작되지 않음**: `Runtime.Start` 내에서 `cfg.Refresh.Enabled` 체크로 guard하므로 기존 Edge 운영에 영향 없다.
- **rejected HTTP status 422**: load/validate 실패 시 `http.StatusUnprocessableEntity`를 반환해 2xx 성공과 명확히 구분한다. dry-run/apply 성공은 200.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Running Edge process에 실제 HTTP request가 도달하며, CLI가 offline validation으로 우회하지 않는지 확인한다.
- `dry-run`과 `apply` 모드가 request/response와 tests에서 분리되어 있는지 확인한다.
- Provider capacity, listen port, invalid YAML/provider config가 S07-S09 기대 classification을 정확히 만족하는지 확인한다.
- Node config push/proto 변경이 이번 refresh 에픽 범위를 넘어 섞이지 않았는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REFRESH_CONTRACT-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config
ok iop/apps/edge/internal/edgecmd 0.014s
ok iop/apps/edge/internal/bootstrap 0.021s
ok iop/apps/edge/cmd/edge 0.036s
ok iop/packages/go/config 0.033s
```
### REFRESH_CONTRACT-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/...
ok iop/apps/edge/cmd/edge 0.097s
ok iop/apps/edge/internal/bootstrap 0.085s
ok iop/apps/edge/internal/configrefresh 0.009s
ok iop/apps/edge/internal/controlplane 4.451s
ok iop/apps/edge/internal/edgecmd 0.021s
ok iop/apps/edge/internal/events 0.005s
ok iop/apps/edge/internal/input 0.011s
ok iop/apps/edge/internal/input/a2a 0.008s
ok iop/apps/edge/internal/node 0.011s
ok iop/apps/edge/internal/openai 1.510s
ok iop/apps/edge/internal/opsconsole 0.012s
ok iop/apps/edge/internal/service 0.440s
ok iop/apps/edge/internal/transport 2.049s
ok iop/packages/go/audit 0.004s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.041s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.008s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.013s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
```
### REFRESH_CONTRACT-3 중간 검증
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
ok iop/apps/edge/cmd/edge 0.065s
ok iop/apps/edge/internal/bootstrap 0.033s
ok iop/apps/edge/internal/configrefresh 0.008s
ok iop/apps/edge/internal/controlplane 4.451s
ok iop/apps/edge/internal/edgecmd 0.017s
ok iop/apps/edge/internal/events 0.004s
ok iop/apps/edge/internal/input 0.007s
ok iop/apps/edge/internal/input/a2a 0.013s
ok iop/apps/edge/internal/node 0.009s
ok iop/apps/edge/internal/openai 1.516s
ok iop/apps/edge/internal/opsconsole 0.008s
ok iop/apps/edge/internal/service 0.437s
ok iop/apps/edge/internal/transport 2.041s
ok iop/packages/go/audit 0.002s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.028s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.005s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.016s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
? iop/proto/gen/iop [no test files]
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
ok iop/apps/edge/cmd/edge 0.065s
ok iop/apps/edge/internal/bootstrap 0.033s
ok iop/apps/edge/internal/configrefresh 0.008s
ok iop/apps/edge/internal/controlplane 4.451s
ok iop/apps/edge/internal/edgecmd 0.017s
ok iop/apps/edge/internal/events 0.004s
ok iop/apps/edge/internal/input 0.007s
ok iop/apps/edge/internal/input/a2a 0.013s
ok iop/apps/edge/internal/node 0.009s
ok iop/apps/edge/internal/openai 1.516s
ok iop/apps/edge/internal/opsconsole 0.008s
ok iop/apps/edge/internal/service 0.437s
ok iop/apps/edge/internal/transport 2.041s
ok iop/packages/go/audit 0.002s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.028s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.005s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.016s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
? iop/proto/gen/iop [no test files]
```
```bash
$ go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config
ok iop/apps/edge/internal/edgecmd 0.014s
ok iop/apps/edge/internal/bootstrap 0.021s
ok iop/apps/edge/cmd/edge 0.036s
ok iop/packages/go/config 0.033s
```
---
> **[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.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan. |
| 검증 결과 | Implementing agent | Fill command output only; command changes require a `계획 대비 변경 사항` entry. |
| 코드리뷰 결과 | Review agent appends | Not included in stub. |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Warn
- implementation deviation: Fail
- verification trust: Fail
- spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/internal/configrefresh/classify.go:12` only calls `config.LoadEdge`, while `apps/edge/internal/edgecmd/config.go:124` still applies `validateEdgeConfig`; this means refresh accepts candidate configs that `iop-edge config check` rejects, such as enabled adapters with empty endpoints at `apps/edge/internal/edgecmd/config.go:248`, `apps/edge/internal/edgecmd/config.go:256`, and `apps/edge/internal/edgecmd/config.go:264`. This violates the plan's same-validator requirement and S09 invalid provider config evidence. Move the shared runtime validator out of the CLI-only path or inject it into refresh candidate loading, then add a refresh rejection test for an adapter/provider config that currently fails `config check`.
- Required: `apps/edge/internal/bootstrap/runtime.go:170` releases `cfgMu` before `configrefresh.Evaluate` reads the `current` config pointer, and `apps/edge/internal/bootstrap/runtime.go:188` mutates the same config/provider backing arrays while the service reads provider data through `apps/edge/internal/service/run_dispatch.go:516` and `apps/edge/internal/service/run_dispatch.go:520`. `Service.SetModelCatalog` also writes an unlocked slice at `apps/edge/internal/service/service.go:47` while dispatch reads it. This introduces data races and inconsistent runtime snapshots as soon as refresh apply is used concurrently with traffic. Use copy-on-write snapshots or proper service/node-store locking, and add deterministic concurrency coverage, preferably including a `go test -race` targeted command.
- Required: `apps/edge/internal/configrefresh/classify.go:108` only classifies provider capacity/max_queue/queue_timeout changes. It misses the planned mutable `models[].providers` diff and also silently ignores provider add/remove or unsupported provider field changes, so `Classify` can return `applied` with `no changes detected` while `applyMutableConfig` still changes `r.Cfg.Models` at `apps/edge/internal/bootstrap/runtime.go:212`. Extend the classifier to report model catalog changes and classify unsupported node/provider structural changes as `restart_required` or `rejected`, with tests for model/provider mapping changes.
- Required: `packages/go/config/config.go:58` adds the new `refresh` config contract, but the user-facing config examples/templates do not expose it: `configs/edge.yaml:30` goes straight from metrics to `control_plane`, and `packages/go/hostsetup/templates.go:32` / `packages/go/hostsetup/templates.go:68` do the same. Since the new CLI requires `refresh.enabled=true`, update `configs/edge.yaml` and both hostsetup Edge templates/tests so operators can discover and enable the admin endpoint consistently with platform-common rules.
- 다음 단계: WARN/FAIL follow-up plan을 생성한다. User-review gate는 트리거하지 않는다.

View file

@ -0,0 +1,259 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=1 tag=REVIEW_REFRESH_CONTRACT -->
# Code Review Reference - REVIEW_REFRESH_CONTRACT
> **[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 selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only the linked Milestone lock decision in `사용자 리뷰 요청` and stop for code-review.
> 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-21
task=m-runtime-reconnect-config-refresh/02_refresh_contract, plan=1, tag=REVIEW_REFRESH_CONTRACT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log`
- Archived review: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- Refresh candidate loading uses only `config.LoadEdge` while `config check` applies `validateEdgeConfig`, so invalid adapter/provider configs can be accepted by refresh.
- Refresh apply mutates runtime config/model catalog without a thread-safe runtime/service snapshot boundary.
- Classifier reports only provider capacity/queue fields and misses planned model catalog/provider structural diffs.
- New `refresh` config contract is absent from `configs/edge.yaml` and hostsetup Edge templates.
- Affected files:
- `apps/edge/internal/configrefresh/*.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/refresh_admin.go`
- `apps/edge/internal/service/*.go`
- `apps/edge/internal/edgecmd/config.go`
- `apps/edge/internal/edgecmd/edgecmd_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `packages/go/hostsetup/templates.go`
- `packages/go/hostsetup/setup_test.go`
- `configs/edge.yaml`
- Verification evidence from prior loop:
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` passed in the archived review.
- `go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config` passed in the archived review.
- Allowed narrow archive reread:
- Read only the two archived files listed above if exact prior context is needed.
## 이 파일을 읽는 리뷰 에이전트에게
> **[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-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REFRESH_CONTRACT-1] Shared refresh validation | [x] |
| [REVIEW_REFRESH_CONTRACT-2] Thread-safe runtime snapshot | [x] |
| [REVIEW_REFRESH_CONTRACT-3] Complete diff classification | [x] |
| [REVIEW_REFRESH_CONTRACT-4] Config examples and templates | [x] |
| [REVIEW_REFRESH_CONTRACT-5] Final verification evidence | [x] |
## 구현 체크리스트
- [x] [REVIEW_REFRESH_CONTRACT-1] Refresh candidate validation is shared with `config check`, and invalid adapter/provider configs are rejected through `/refresh`.
- [x] [REVIEW_REFRESH_CONTRACT-2] Refresh apply uses a thread-safe runtime/service snapshot boundary with deterministic concurrency coverage.
- [x] [REVIEW_REFRESH_CONTRACT-3] Config diff classification reports model catalog/provider structural changes instead of silently returning `no changes detected`.
- [x] [REVIEW_REFRESH_CONTRACT-4] The new `refresh` config contract is represented in user-facing Edge config examples/templates and covered by tests.
- [x] [REVIEW_REFRESH_CONTRACT-5] Final verification commands are executed and the review stub records actual stdout/stderr.
- [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-runtime-reconnect-config-refresh/02_refresh_contract/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.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가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- 계획 범위의 thread-safe snapshot 보완을 닫기 위해 service뿐 아니라 OpenAI provider-pool catalog reader와 transport registration NodeStore reader까지 같은 copy-on-write 교체 경계에 포함했다.
- `configrefresh.Classify`는 mixed mutable/immutable 변경을 부분 적용하지 않도록 `restart_required`가 하나라도 있으면 apply mutation을 수행하지 않는 기존 status contract를 유지했다.
- field/full-cycle 실제 구동은 이 review-fix plan의 고정 검증 명령에 포함되지 않았고, 이번 변경은 local unit/race 검증으로 한정했다.
## 주요 설계 결정
- `apps/edge/internal/edgevalidate`를 shared validator로 두고 `iop-edge config check`와 `configrefresh.LoadCandidate`가 같은 validation path를 사용하게 했다. enabled `openai_compat` endpoint 누락은 `/refresh`에서도 `rejected`가 된다.
- refresh apply는 기존 `Runtime.Cfg` 또는 provider slice를 in-place로 바꾸지 않고, validated candidate config와 새 NodeStore를 만든 뒤 Runtime/transport/service/input catalog snapshot을 교체한다.
- service는 NodeStore와 model catalog를 하나의 lock으로 snapshot하며, model catalog map은 copy-on-write로 보관/반환한다. OpenAI server도 provider-pool catalog를 lock/copy snapshot으로 읽는다.
- classifier는 provider capacity/max_queue/queue_timeout_ms를 `applied`, top-level model catalog provider mapping을 `applied`, provider add/remove 및 type/category/adapter/models/health/lifecycle changes와 node/top-level runtime surface changes를 `restart_required`로 분류한다.
- `refresh.enabled=false`, `refresh.listen=127.0.0.1:19093`을 tracked `configs/edge.yaml`과 hostsetup installed/bundle templates에 노출했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Refresh candidate loading and `config check` share the same runtime validation path.
- Refresh apply cannot race with provider pool dispatch, status snapshots, or model catalog reads.
- `Result.Changes` includes model catalog/provider structural diffs and never hides unsupported changes as `no changes detected`.
- `refresh.enabled` and `refresh.listen` are visible in tracked Edge examples/templates with safe defaults.
- Final verification includes the targeted race command and the full Edge/config/proto command.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REFRESH_CONTRACT-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/edgecmd ./packages/go/config
ok iop/apps/edge/internal/configrefresh 0.016s
ok iop/apps/edge/internal/edgecmd 0.015s
ok iop/packages/go/config 0.032s
```
### REVIEW_REFRESH_CONTRACT-2 중간 검증
```bash
$ go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
ok iop/apps/edge/internal/bootstrap 1.218s
ok iop/apps/edge/internal/service 1.482s
```
### REVIEW_REFRESH_CONTRACT-3 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh
ok iop/apps/edge/internal/configrefresh 0.009s
```
### REVIEW_REFRESH_CONTRACT-4 중간 검증
```bash
$ go test -count=1 ./packages/go/hostsetup ./packages/go/config
ok iop/packages/go/hostsetup 0.006s
ok iop/packages/go/config 0.045s
```
### 최종 검증
```bash
$ go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
ok iop/apps/edge/internal/bootstrap 1.218s
ok iop/apps/edge/internal/service 1.482s
```
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
ok iop/apps/edge/cmd/edge 0.096s
ok iop/apps/edge/internal/bootstrap 0.123s
ok iop/apps/edge/internal/configrefresh 0.032s
ok iop/apps/edge/internal/controlplane 4.460s
ok iop/apps/edge/internal/edgecmd 0.031s
? iop/apps/edge/internal/edgevalidate [no test files]
ok iop/apps/edge/internal/events 0.012s
ok iop/apps/edge/internal/input 0.009s
ok iop/apps/edge/internal/input/a2a 0.008s
ok iop/apps/edge/internal/node 0.014s
ok iop/apps/edge/internal/openai 1.512s
ok iop/apps/edge/internal/opsconsole 0.008s
ok iop/apps/edge/internal/service 0.469s
ok iop/apps/edge/internal/transport 2.022s
ok iop/packages/go/audit 0.005s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.084s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.016s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.052s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
? iop/proto/gen/iop [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.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| Archive Evidence Snapshot | Fixed at stub creation from plan | Implementing agent may use it as prior-loop context; only listed archive files may be narrowly reread. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan. |
| 검증 결과 | Implementing agent | Fill command output only; command changes require a `계획 대비 변경 사항` entry. |
| 코드리뷰 결과 | Review agent appends | Not included in stub. |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- implementation deviation: Warn
- verification trust: Fail
- spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/cmd/edge/main.go:68` and `apps/edge/cmd/edge/main.go:78` normalize the runtime config used by `iop-edge serve`: empty `logging.path` becomes `<binary-dir>/logs/edge.log`, and relative or empty `bootstrap.artifact_dir` becomes a binary-dir path through `apps/edge/internal/edgecmd/paths.go:53` and `apps/edge/internal/edgecmd/paths.go:64`. Refresh candidates do not go through that same runtime normalization path: `apps/edge/internal/configrefresh/classify.go:15` only calls `config.LoadEdge` plus validation. Because the classifier then compares `bootstrap.artifact_dir` and full `logging` as `restart_required` at `apps/edge/internal/configrefresh/classify.go:138` and `apps/edge/internal/configrefresh/classify.go:140`, an unchanged operator YAML such as omitted `logging.path` plus `bootstrap.artifact_dir: "artifacts"` will be seen as different from the running process. In the real `serve` path, a capacity-only refresh can therefore return `restart_required` instead of S07 `applied`, and apply mode will refuse to commit the mutable change. The current running-runtime tests miss this because they build `currentCfg` with `config.LoadEdge` directly at `apps/edge/internal/bootstrap/runtime_test.go:263` and `apps/edge/internal/bootstrap/runtime_test.go:402`, bypassing `loadRuntimeConfig` normalization. Fix by making refresh candidate loading use the same runtime-normalized config contract as `serve` or by normalizing both current and candidate before classification, then add a regression test where the current runtime config has serve-normalized paths and the candidate YAML uses the same source-level values while only provider capacity changes.
- 다음 단계: WARN/FAIL follow-up plan을 생성한다. User-review gate는 트리거하지 않는다.

View file

@ -0,0 +1,238 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=3 tag=REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT -->
# Code Review Reference - REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT
> **[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 selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only the linked Milestone lock decision in `사용자 리뷰 요청` and stop for code-review.
> 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-21
task=m-runtime-reconnect-config-refresh/02_refresh_contract, plan=3, tag=REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G05_2.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G05_2.log`
- Verdict: FAIL
- Required summary:
- `configrefresh.applyRuntimeNormalization` cleans explicitly configured relative `logging.path` values with `filepath.Clean`.
- `iop-edge serve` does not clean explicit log paths; `edgecmd.ResolveEdgeLogPath` returns non-empty explicit values unchanged.
- Therefore source-identical YAML with `logging.path: "./logs/edge.log"` can still be classified as `restart_required` against the running process when only provider capacity changed.
- The new regression tests cover empty `logging.path`, but not explicit relative `logging.path`, and the test helper duplicates the same clean behavior.
- Prior loop context:
- Earlier archives: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_1.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_1.log`.
- Current loop verification commands all passed, but they do not cover explicit relative log paths.
- Affected files:
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/configrefresh/classify_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go` if runtime-level helper duplication is adjusted
- `apps/edge/internal/edgecmd/paths.go` or a new import-safe helper location if normalization is shared
- Verification evidence from current review:
- `go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd` passed.
- `go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service` passed.
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` passed.
- Allowed narrow archive reread:
- Read only the six archive files listed above if exact prior-loop context is needed.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G04.md` -> `code_review_local_G04_N.log`, `PLAN-local-G04.md` -> `plan_local_G04_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-1] Exact serve logging path contract | [x] |
| [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-2] Explicit relative path regression | [x] |
| [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-3] Final verification evidence | [x] |
## 구현 체크리스트
- [x] [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-1] Refresh candidate logging path normalization exactly matches `iop-edge serve` for empty, absolute, and explicit relative `logging.path`.
- [x] [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-2] Regression tests cover explicit relative `logging.path` with capacity-only refresh and prove no `logging` restart-required diff is produced.
- [x] [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-3] Final verification commands are executed and the review stub records actual stdout/stderr.
- [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_G04_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G04_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-runtime-reconnect-config-refresh/02_refresh_contract/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G04.md`와 `CODE_REVIEW-local-G04.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가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
계획에서 제시한 narrow fix를 정확히 수행했으며, 계획 대비 별도 변경사항 없음.
## 주요 설계 결정
1. `applyRuntimeNormalization`에서 explicit relative `logging.path`에 대한 `filepath.Clean` 호출을 완전히 제거했다. empty path만 `defaultEdgeLogPath()`로 resolve되고, absolute/relative 모두 원본을 그대로 유지한다.
2. test helper `buildNormalizedCurrent`도 동일하게 수정: explicit relative `logging.path`에 clean을 호출하지 않도록 변경.
3. `bootstrap.artifact_dir` 관련 행동은 변경하지 않았다. 기존 `resolveBootstrapArtifactDir`이 이미 absolute check 후 `filepath.Join(binaryDir(), dir)`로 절대경로를 생산하므로 alignment 유지.
4. explicit relative `logging.path: "./logs/edge.log"`를 사용하는 capacity-only refresh 테스트(`TestClassifyExplicitRelativeLoggingPathNoRestartRequired`)를 추가하여 회귀를 차단.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Refresh candidate normalization preserves explicit relative `logging.path` exactly like `edgecmd.ResolveEdgeLogPath`.
- Empty `logging.path` and `bootstrap.artifact_dir` behavior from the previous loop remains covered.
- The regression test fails before the fix and passes after it.
- Final verification includes targeted configrefresh/bootstrap/cmd/edge/edgecmd tests, race tests, and full Edge/config/proto tests.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/edgecmd
ok iop/apps/edge/internal/configrefresh 0.012s
ok iop/apps/edge/internal/edgecmd 0.016s
```
### REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
ok iop/apps/edge/internal/configrefresh 0.014s
ok iop/apps/edge/internal/bootstrap 0.063s
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd
ok iop/apps/edge/internal/configrefresh 0.022s
ok iop/apps/edge/internal/bootstrap 0.072s
ok iop/apps/edge/cmd/edge 0.061s
ok iop/apps/edge/internal/edgecmd 0.034s
```
```bash
$ go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
ok iop/apps/edge/internal/bootstrap 1.199s
ok iop/apps/edge/internal/service 1.479s
```
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
ok iop/apps/edge/cmd/edge 0.061s
ok iop/apps/edge/internal/bootstrap 0.072s
ok iop/apps/edge/internal/configrefresh 0.022s
ok iop/apps/edge/internal/controlplane 4.450s
ok iop/apps/edge/internal/edgecmd 0.034s
? iop/apps/edge/internal/edgevalidate [no test files]
ok iop/apps/edge/internal/events 0.010s
ok iop/apps/edge/internal/input 0.013s
ok iop/apps/edge/internal/input/a2a 0.012s
ok iop/apps/edge/internal/node 0.017s
ok iop/apps/edge/internal/openai 1.509s
ok iop/apps/edge/internal/opsconsole 0.011s
ok iop/apps/edge/internal/service 0.447s
ok iop/apps/edge/internal/transport 2.041s
ok iop/packages/go/audit 0.007s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.029s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.009s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.017s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
? iop/proto/gen/iop [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.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| Archive Evidence Snapshot | Fixed at stub creation from plan | Implementing agent may use it as prior-loop context; only listed archive files may be narrowly reread. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan. |
| 검증 결과 | Implementing agent | Fill command output only; command changes require a `계획 대비 변경 사항` entry. |
| 코드리뷰 결과 | Review agent appends | Not included in stub. |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- implementation deviation: Pass
- verification trust: Pass
- spec conformance: Pass
- 발견된 문제: 없음
- 다음 단계: PASS finalization을 진행한다.

View file

@ -0,0 +1,243 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=2 tag=REVIEW_REVIEW_REFRESH_CONTRACT -->
# Code Review Reference - REVIEW_REVIEW_REFRESH_CONTRACT
> **[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 selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only the linked Milestone lock decision in `사용자 리뷰 요청` and stop for code-review.
> 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-21
task=m-runtime-reconnect-config-refresh/02_refresh_contract, plan=2, tag=REVIEW_REVIEW_REFRESH_CONTRACT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_1.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_1.log`
- Verdict: FAIL
- Required summary:
- `iop-edge serve` normalizes runtime config after load: `logging.path` is resolved with `edgecmd.ResolveEdgeLogPath`, and `bootstrap.artifact_dir` is resolved with `edgecmd.ResolveBootstrapArtifactDir`.
- `configrefresh.LoadCandidate` loads and validates the candidate but does not apply the same runtime normalization.
- `configrefresh.Classify` compares `bootstrap.artifact_dir` and full `logging` as `restart_required`, so source-level identical YAML can be classified as restart-required against a running serve-normalized process.
- Existing running-runtime tests build `currentCfg` through `config.LoadEdge` directly, so they miss the real `serve` path mismatch.
- Prior loop context:
- Earlier FAIL archive: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log`.
- The current loop fixed shared validation, thread-safe snapshots, model/provider diff classification, refresh templates, and final verification evidence.
- Affected files:
- `apps/edge/cmd/edge/main.go`
- `apps/edge/internal/edgecmd/paths.go`
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/configrefresh/classify_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- Verification evidence from current review:
- `go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service` passed.
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` passed.
- These commands are not sufficient to close the issue because no test covers a serve-normalized current config against a source-level refresh candidate.
- Allowed narrow archive reread:
- Read only the four archive files listed above if exact prior-loop context is needed.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G05.md` -> `code_review_local_G05_N.log`, `PLAN-local-G05.md` -> `plan_local_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_REFRESH_CONTRACT-1] Runtime-normalized refresh candidate | [x] |
| [REVIEW_REVIEW_REFRESH_CONTRACT-2] Serve-path regression coverage | [x] |
| [REVIEW_REVIEW_REFRESH_CONTRACT-3] Final verification evidence | [x] |
## 구현 체크리스트
- [x] [REVIEW_REVIEW_REFRESH_CONTRACT-1] Refresh classification uses the same runtime-normalized config contract as `iop-edge serve` for `logging.path` and `bootstrap.artifact_dir`.
- [x] [REVIEW_REVIEW_REFRESH_CONTRACT-2] Regression tests cover a serve-normalized current config plus source-level candidate YAML where only provider capacity changes and the result remains `applied`.
- [x] [REVIEW_REVIEW_REFRESH_CONTRACT-3] Final verification commands are executed and the review stub records actual stdout/stderr.
- [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_G05_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G05_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-runtime-reconnect-config-refresh/02_refresh_contract/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/02_refresh_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G04.md`와 `CODE_REVIEW-local-G04.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가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
계획에서 제시한 `export an import-safe helper` 대신, internal package에 `applyRuntimeNormalization` 함수를 추가하여 `LoadCandidate`에서 호출하도록 구현했다. 테스트에서 `binaryDir`, `resolveBootstrapArtifactDir`는 external test에서 접근 불가하므로 동일한 정규화 로직을 테스트용 헬퍼 함수로 재구현했다.
또한 기존 `bootstrap/runtime_test.go`의 통합 테스트들이 serve-normalized current config를 사용하지 않아 FAIL하고 있어, 테스트용 정규화 헬퍼(`serveNormalizedLogPath`, `serveNormalizedArtifactDir`)를 추가하여 current config를 정규화하도록 수정했다.
## 주요 설계 결정
1. **정규화 위치**: `applyRuntimeNormalization`을 `LoadCandidate` 호출 직후에 실행하여 candidate가 Classify에 들어가기 전부터 effective 값을 갖도록 했다.
2. **logging.path 정규화**: `edgecmd.ResolveEdgeLogPath`와 동일하게 empty이면 binary 디렉터리의 `logs/edge.log`로 해결하되, test 환경에서 os.Executable이 실제로 동작하지 않으므로 fallback 로직을 포함했다.
3. **bootstrap.artifact_dir 정규화**: `edgecmd.ResolveBootstrapArtifactDir`와 동일하게 relative path를 absolute path로 변환한다.
4. ** classifier 무시하지 않음**: `logging`과 `bootstrap.artifact_dir`를 restart-required에서 제외하지 않는다. effective 값이 실제로 변경된 경우에만 restart-required로 분류된다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `serve`와 refresh 후보 로딩이 `logging.path`, `bootstrap.artifact_dir`의 같은 effective-value normalization을 공유한다.
- `Classify`는 effective value가 실제로 바뀐 경우에만 `logging` 또는 `bootstrap.artifact_dir`을 `restart_required`로 보고한다.
- 회귀 테스트는 serve-normalized current config와 source-level candidate YAML의 불일치를 직접 재현한다.
- 최종 검증에는 targeted configrefresh/bootstrap/cmd/edge/edgecmd 테스트, race 테스트, full Edge/config/proto 테스트가 포함된다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REVIEW_REFRESH_CONTRACT-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/configrefresh ./apps/edge/internal/edgecmd
ok iop/apps/edge/cmd/edge 0.037s
ok iop/apps/edge/internal/configrefresh 0.018s
ok iop/apps/edge/internal/edgecmd 0.015s
```
### REVIEW_REVIEW_REFRESH_CONTRACT-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
ok iop/apps/edge/internal/configrefresh 0.018s
ok iop/apps/edge/internal/bootstrap 0.053s
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd
ok iop/apps/edge/internal/configrefresh 0.018s
ok iop/apps/edge/internal/bootstrap 0.053s
ok iop/apps/edge/cmd/edge 0.040s
ok iop/apps/edge/internal/edgecmd 0.016s
```
```bash
$ go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
ok iop/apps/edge/internal/bootstrap 1.295s
ok iop/apps/edge/internal/service 1.520s
```
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
ok iop/apps/edge/cmd/edge 0.087s
ok iop/apps/edge/internal/bootstrap 0.084s
ok iop/apps/edge/internal/configrefresh 0.033s
ok iop/apps/edge/internal/controlplane 4.457s
ok iop/apps/edge/internal/edgecmd 0.031s
? iop/apps/edge/internal/edgevalidate [no test files]
ok iop/apps/edge/internal/events 0.015s
ok iop/apps/edge/internal/input 0.015s
ok iop/apps/edge/internal/input/a2a 0.013s
ok iop/apps/edge/internal/node 0.011s
ok iop/apps/edge/internal/openai 1.512s
ok iop/apps/edge/internal/opsconsole 0.011s
ok iop/apps/edge/internal/service 0.461s
ok iop/apps/edge/internal/transport 2.043s
ok iop/packages/go/audit 0.009s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.035s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.012s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.014s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
? iop/proto/gen/iop [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.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| Archive Evidence Snapshot | Fixed at stub creation from plan | Implementing agent may use it as prior-loop context; only listed archive files may be narrowly reread. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan. |
| 검증 결과 | Implementing agent | Fill command output only; command changes require a `계획 대비 변경 사항` entry. |
| 코드리뷰 결과 | Review agent appends | Not included in stub. |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Warn
- implementation deviation: Fail
- verification trust: Pass
- spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/internal/configrefresh/classify.go:21` changes an explicitly configured relative `logging.path` with `filepath.Clean`, but the real `serve` path keeps explicit log paths unchanged: `apps/edge/internal/edgecmd/paths.go:53` returns `explicit` as-is and `apps/edge/cmd/edge/main.go:68` stores that value into the running config. This means an operator config with `logging.path: "./logs/edge.log"` can still be source-identical except for provider capacity, yet the running process holds `"./logs/edge.log"` while the refresh candidate becomes `"logs/edge.log"`. Because `Classify` compares full `logging` as `restart_required` at `apps/edge/internal/configrefresh/classify.go:190`, the capacity-only refresh is again reported as `restart_required` instead of S07 `applied`. The regression tests only cover empty `logging.path`, and their test helper duplicates the same `filepath.Clean` behavior at `apps/edge/internal/configrefresh/classify_test.go:35`, so this mismatch is not caught. Fix the refresh normalization to exactly match `edgecmd.ResolveEdgeLogPath` for explicit values, preferably by moving the shared effective-config helper to an import-safe package and using it from both `serve` and refresh; add a regression test with explicit relative `logging.path: "./logs/edge.log"` plus a capacity-only candidate.
- 다음 단계: WARN/FAIL follow-up plan을 생성한다. User-review gate는 트리거하지 않는다.

View file

@ -0,0 +1,50 @@
# Complete - m-runtime-reconnect-config-refresh/02_refresh_contract
## 완료 일시
2026-06-21
## 요약
Refresh entrypoint and diff-classify contract completed after 4 review loops; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | shared validation, thread-safe runtime snapshot, diff coverage, refresh templates, and evidence gaps required follow-up |
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | FAIL | serve-normalized config path mismatch required follow-up |
| `plan_local_G05_2.log` | `code_review_local_G05_2.log` | FAIL | explicit relative `logging.path` mismatch required follow-up |
| `plan_local_G04_3.log` | `code_review_local_G04_3.log` | PASS | explicit relative `logging.path` contract fixed and verified |
## 구현/정리 내용
- Added Edge-local config refresh entrypoint with dry-run/apply responses reaching a running Edge process.
- Added refresh candidate validation, config diff classification, applied/restart_required/rejected status reporting, and model/provider diff coverage.
- Added copy-on-write runtime/service/input/OpenAI/transport snapshot handling for refresh apply.
- Added refresh config defaults to examples/templates.
- Aligned refresh candidate runtime normalization with `iop-edge serve`, including empty and explicit relative `logging.path` behavior.
- Added regression coverage for serve-normalized current config, explicit relative `logging.path`, provider capacity applied, listen port restart_required, and invalid config rejected cases.
## 최종 검증
- `go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd` - PASS; packages returned ok.
- `go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service` - PASS; packages returned ok under race detector.
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` - PASS; all listed packages returned ok or no test files.
- `go test -count=1 ./apps/edge/internal/configrefresh` - PASS; post-review comment-only cleanup targeted rerun returned ok.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Completed task ids:
- `entrypoint`: PASS; evidence=`agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G04_3.log`, `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G04_3.log`; verification=`go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd`
- `diff-classify`: PASS; evidence=`agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G04_3.log`, `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G04_3.log`; verification=`go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,374 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=0 tag=REFRESH_CONTRACT -->
# Plan - REFRESH_CONTRACT
## 이 파일을 읽는 구현 에이전트에게
이 계획을 구현한 뒤에는 반드시 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운다. 구현이 끝나도 review finalization, 로그 rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이며 구현 에이전트가 수행하지 않는다.
선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션을 정확한 연결 근거와 함께 채우고 멈춘다. 구현 중 사용자에게 직접 묻거나, chat 선택지를 제시하거나, `request_user_input`을 호출하거나, `USER_REVIEW.md`를 만들지 않는다. 환경/secret/service 준비, 일반 범위 조정, 검증 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과나 후속 계획으로 기록한다.
## 배경
현재 Edge는 시작 시 `edge.yaml`을 한 번 읽어 runtime을 조립하고, 운영 중 config refresh를 받는 표면이 없다. Milestone의 refresh 에픽은 실행 중 Edge process에 도달하는 명시 entrypoint와 dry-run/apply 모드, 그리고 mutable/immutable/rejected diff 분류를 요구한다. 이 계획은 runtime apply hardening과 Node push를 제외하고, refresh 요청/응답 계약과 classifier의 첫 구현 범위를 고정한다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone 잠금 결정이 실구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 사용하며, 직접 사용자 prompt는 금지된다. code-review가 요청의 정당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `AGENTS.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md`
- `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.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`
- `agent-test/local/testing-smoke.md`
- `apps/edge/internal/edgecmd/config.go`
- `apps/edge/internal/edgecmd/root.go`
- `apps/edge/internal/edgecmd/edgecmd_test.go`
- `apps/edge/cmd/edge/main.go`
- `apps/edge/internal/bootstrap/module.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `apps/edge/internal/input/manager.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/routes.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/service_internal_test.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/node/store.go`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/node/registry.go`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `proto/iop/runtime.proto`
- `configs/edge.yaml`
### SDD 기준
- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`, 상태 `[승인됨]`, SDD 잠금 `해제`.
- 대상 Acceptance Scenario:
- S06 -> `entrypoint`: running Edge process가 dry-run/apply refresh 요청을 받고 mode별 결과를 반환해야 한다.
- S07 -> `diff-classify`: provider capacity 변경은 `applied`로 분류된다.
- S08 -> `diff-classify`: listen port 변경은 `restart_required`로 분류되고 live apply되지 않는다.
- S09 -> `diff-classify`: invalid YAML 또는 invalid provider config는 `rejected`이고 기존 상태가 유지된다.
- Evidence Map:
- S06: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract`, refresh entrypoint unit/integration test.
- S07: same task, provider capacity classifier test.
- S08: same task, listen port classifier test.
- S09: same task, invalid config refresh test.
- 반영 방식: 구현 checklist는 Edge-local admin API와 CLI가 running process에 도달하는지, dry-run/apply 모드가 응답에 분리되는지, classifier가 S07-S09 세 케이스를 deterministic test로 증명하는지에서 역산한다.
### 테스트 환경 규칙
- `test_env=local`.
- `agent-test/local/rules.md`가 존재해 전체를 읽었다.
- 매칭 profile:
- `agent-test/local/edge-smoke.md`: Edge 단위 테스트 `go test ./apps/edge/...`, smoke `./scripts/e2e-smoke.sh`, e2e `make test-e2e`.
- `agent-test/local/platform-common-smoke.md`: config/proto 공통 확인 `go test ./packages/go/... ./proto/gen/...`, proto 변경 시 `make proto`.
- `agent-test/local/testing-smoke.md`: 검증 규칙 변경 시 테스트 규칙 자체 확인. 이번 계획에서는 테스트 규칙 파일을 수정하지 않는다.
- 적용 명령은 Go cache를 허용하지 않도록 `-count=1`을 사용한다. 새 외부 dependency는 추가하지 않으므로 manifest fallback은 필요 없다.
### 테스트 커버리지 공백
- Running Edge refresh entrypoint: 기존 테스트 없음. `apps/edge/internal/bootstrap` 또는 `apps/edge/cmd/edge` 레벨에서 local admin API에 dry-run/apply 요청이 도달하는 통합 테스트가 필요하다.
- CLI refresh command: 기존 `edgecmd` 테스트는 config/node command 중심이며 refresh command가 없다. CLI flag, mode, HTTP request/response 출력 테스트가 필요하다.
- Diff classifier: provider capacity, listen port, invalid YAML/provider config classifier 테스트가 없다. 새 package 단위 테스트가 필요하다.
- 기존 config validation: `packages/go/config/config_test.go`는 provider/model validation을 넓게 커버하지만 runtime current-vs-candidate diff는 커버하지 않는다.
### 심볼 참조
- renamed/removed symbol 없음.
- 새 public-ish internal API 후보: `configrefresh.Classify`, `configrefresh.Result`, `Runtime.Refresh`, `edgecmd.RefreshCmd`는 신규 추가라 기존 call site 없음.
### 분할 판단
- split decision policy를 plan 파일 선택 전에 평가했다.
- 공유 task group: `agent-task/m-runtime-reconnect-config-refresh`.
- sibling plan:
- `02_refresh_contract`: entrypoint와 diff classifier. 선행 의존성 없음.
- `03+02_edge_refresh_apply`: atomic apply와 ops report. `02_refresh_contract`의 `complete.log`가 필요하다.
- API/contract와 runtime apply hardening은 위험도와 검증 전략이 달라 split한다. 이 plan은 S06-S09만 check-on-pass하고, S10-S11은 dependent plan에서 처리한다.
### 범위 결정 근거
- 이번 plan은 Node config push/ack `push-contract`를 제외한다. SDD Evidence Map에는 S12가 같은 `02_refresh_contract` 경로로 적혀 있지만, 사용자의 실행 범위는 refresh 에픽의 네 작업이며 S12는 node-config 에픽이다.
- Node runtime, adapter registry, generated proto 변경은 제외한다.
- 자동 file watcher hot reload와 frontend refresh UI는 Milestone 범위 제외에 따라 제외한다.
- 실제 apply 실패 rollback과 ops report completeness는 `03+02_edge_refresh_apply`에서 다룬다. 이 plan의 apply mode는 running process 도달, mode 구분, classify 결과 반환, mutable-only happy path hook까지로 제한한다.
### 빌드 등급
- `cloud-G07`: running process entrypoint, CLI/API stdout/HTTP contract, process lifecycle test가 중심이라 local allowlist를 넘는다. 범위는 Edge 내부로 제한되므로 G08 이상은 dependent apply hardening plan에 남긴다.
## 구현 체크리스트
- [ ] [REFRESH_CONTRACT-1] Edge-local refresh admin API와 `iop-edge config refresh` CLI를 추가해 running Edge process에 dry-run/apply 요청이 도달하게 한다.
- [ ] [REFRESH_CONTRACT-2] candidate config load/validation/diff classifier를 추가하고 provider capacity는 `applied`, listen port는 `restart_required`, invalid YAML/provider config는 `rejected`로 테스트한다.
- [ ] [REFRESH_CONTRACT-3] `entrypoint`와 `diff-classify` 대상 최종 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## REFRESH_CONTRACT-1 - Running Process Entrypoint
### 문제
`config check`는 `LoadEdgeConfig`로 파일을 읽어 검증만 하고 종료한다. 실행 중 Edge process와 연결되는 refresh 표면은 없다.
Before:
```go
// apps/edge/internal/edgecmd/config.go:110
func configCheckCmd() *cobra.Command {
return &cobra.Command{
Use: "check",
Short: "Validate edge config can be loaded",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, path, err := LoadEdgeConfig(cmd)
```
`serve`도 시작 시 config를 한 번 읽고 `bootstrap.Module(cfg)`를 실행한다.
```go
// apps/edge/cmd/edge/main.go:38
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := loadRuntimeConfig(cmd)
if err != nil {
return err
}
fx.New(bootstrap.Module(cfg)).Run()
return nil
},
```
### 해결 방법
- MVP 표면은 "Edge-local admin API + CLI wrapper"로 고정한다.
- `packages/go/config.EdgeConfig`에 `Refresh EdgeRefreshConf`를 추가한다. 기본은 disabled, listen 기본값은 loopback으로 둔다. 테스트는 `refresh.enabled=true`, `refresh.listen=127.0.0.1:0`을 사용한다.
- `bootstrap.Runtime`에 refresh admin server를 추가하고, enabled일 때 `POST /refresh`를 받는다. request body는 `{mode, config_path, request_id}`이고 mode는 `dry_run|apply`만 허용한다.
- `edgecmd config refresh`는 `--mode dry-run|apply`, `--addr`, `--config`를 받아 running Edge의 admin API로 요청하고 JSON 결과를 출력한다. `--mode` 기본은 `dry-run`으로 둔다.
- `apply` 모드는 이 plan에서 classifier 결과와 mutable-only happy path를 반환하되, apply failure rollback 증명은 REFRESH_APPLY plan으로 넘긴다.
After shape:
```go
// apps/edge/internal/bootstrap/runtime.go
type Runtime struct {
Cfg *config.EdgeConfig
Refresh *RefreshAdminServer
}
func (r *Runtime) RefreshConfig(ctx context.Context, req configrefresh.Request) (configrefresh.Result, error) {
return configrefresh.Evaluate(ctx, r.Cfg, req)
}
```
```go
// apps/edge/internal/edgecmd/config.go
c.AddCommand(configInitCmd(), configPrintCmd(), configCheckCmd(), configRefreshCmd())
```
### 수정 파일 및 체크리스트
- [ ] `packages/go/config/config.go`: `EdgeRefreshConf`와 `EdgeConfig.Refresh` 추가, defaults 추가.
- [ ] `packages/go/config/config_test.go`: refresh defaults/unmarshal test 추가.
- [ ] `apps/edge/internal/bootstrap/runtime.go`: refresh admin server lifecycle wiring 추가.
- [ ] `apps/edge/internal/bootstrap/refresh_admin.go`: local HTTP handler 추가.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: running runtime에 dry-run/apply 요청 도달 테스트 추가.
- [ ] `apps/edge/internal/edgecmd/config.go`: `config refresh` command 추가.
- [ ] `apps/edge/internal/edgecmd/edgecmd_test.go`: CLI request/JSON output/invalid mode test 추가.
- [ ] `apps/edge/cmd/edge/main.go`: running config path를 Runtime refresh 기본값으로 전달할 수 있게 보존한다.
### 테스트 작성
- 작성: `apps/edge/internal/bootstrap/runtime_test.go`에 `TestRefreshAdminDryRunAndApplyReachRunningRuntime`.
- 작성: `apps/edge/internal/edgecmd/edgecmd_test.go`에 `TestConfigRefreshCommandPostsModeAndConfigPath`, `TestConfigRefreshCommandRejectsInvalidMode`.
- 작성: `packages/go/config/config_test.go`에 `TestEdgeRefreshDefaults`.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config
```
예상: refresh config schema, admin API lifecycle, CLI wrapper 테스트가 모두 PASS.
## REFRESH_CONTRACT-2 - Diff Classifier
### 문제
현재 runtime 조립은 config 값을 서비스에 직접 주입하고 끝난다. current config와 candidate config의 차이를 `applied`, `restart_required`, `rejected`로 분류하는 경계가 없다.
Before:
```go
// apps/edge/internal/bootstrap/runtime.go:48
registry := edgenode.NewRegistry()
nodeStore, err := edgenode.LoadFromConfig(cfg.Nodes)
if err != nil {
return nil, fmt.Errorf("edge: seed node store: %w", err)
}
svc := edgeservice.New(registry, bus)
svc.SetNodeStore(nodeStore)
svc.SetModelCatalog(cfg.Models)
```
Provider dispatch는 현재 `s.modelCatalog`와 `s.nodeStore`를 읽지만 refresh diff source가 없다.
```go
// apps/edge/internal/service/run_dispatch.go:491
func (s *Service) resolveProviderPoolCandidates(req SubmitRunRequest) ([]candidateNode, groupPolicy, error) {
var catalogEntry *config.ModelCatalogEntry
for i := range s.modelCatalog {
```
### 해결 방법
- 새 package `apps/edge/internal/configrefresh`를 추가한다.
- `LoadCandidate(path)`는 `config.LoadEdge`와 shared runtime validation을 수행한다. 기존 `edgecmd.validateEdgeConfig` 로직은 CLI 전용 private 함수에서 공유 가능한 validator로 이동하거나 wrapper를 둔다.
- `Classify(current, candidate)`는 stable key 기준으로 changed node/provider/model paths를 만든다.
- Mutable `applied`:
- `nodes[].providers[].capacity`
- `nodes[].providers[].max_queue`
- `nodes[].providers[].queue_timeout_ms`
- `models[].providers`가 기존 provider id와 served model membership validation을 통과하는 변경
- `restart_required`:
- `server.listen`
- `bootstrap.listen`
- `bootstrap.artifact_dir`
- `openai.listen`
- `a2a.listen`
- `metrics.port`
- `edge.id`, `edge.name`
- `rejected`:
- YAML parse/load error
- provider validation error
- duplicate provider/model id
- unsupported mixed mutable and immutable apply request if implementation cannot safely apply partial changes
- Result는 `status`, `mode`, `request_id`, `changes[]`, `summary`를 포함한다.
After shape:
```go
// apps/edge/internal/configrefresh/result.go
type Status string
const (
StatusApplied Status = "applied"
StatusRestartRequired Status = "restart_required"
StatusRejected Status = "rejected"
)
type Change struct {
Path string `json:"path"`
Class Status `json:"class"`
Previous string `json:"previous,omitempty"`
Next string `json:"next,omitempty"`
}
```
### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/configrefresh/*.go`: request/result types, candidate loading, validation wrapper, classifier 구현.
- [ ] `apps/edge/internal/configrefresh/*_test.go`: S07-S09 classifier tests 추가.
- [ ] `apps/edge/internal/edgecmd/config.go`: `config check`와 refresh가 같은 validator를 쓰게 정리.
- [ ] `apps/edge/internal/bootstrap/refresh_admin.go`: rejected/restart_required/applied 결과를 HTTP status와 JSON body로 반환.
### 테스트 작성
- 작성: `TestClassifyProviderCapacityApplied`.
- 작성: `TestClassifyListenPortRestartRequired`.
- 작성: `TestRefreshInvalidYAMLRejected`.
- 작성: `TestRefreshInvalidProviderRejected`.
- fixture는 `t.TempDir()`에 current/candidate YAML을 쓰고 `config.LoadEdge`와 classifier를 통과시킨다.
### 중간 검증
```bash
go test -count=1 ./apps/edge/... ./packages/go/...
```
예상: 새 classifier package를 포함한 Edge와 shared config 테스트가 모두 PASS.
## REFRESH_CONTRACT-3 - Final Evidence
### 문제
Milestone Task의 검증은 SDD Evidence Map의 S06-S09를 complete.log에서 복사 가능한 형태로 남겨야 한다. 구현만 하고 review stub을 비워두면 loop가 완료 증거를 만들 수 없다.
### 해결 방법
- 최종 검증 명령을 모두 실행한다.
- `CODE_REVIEW-cloud-G07.md`의 구현 항목별 완료 여부, 구현 체크리스트, 계획 대비 변경 사항, 주요 설계 결정, 검증 결과를 실제 내용으로 채운다.
- 출력이 길면 command와 저장 경로를 함께 기록한다.
### 수정 파일 및 체크리스트
- [ ] `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/CODE_REVIEW-cloud-G07.md`: 구현 에이전트 소유 섹션 작성.
### 테스트 작성
- 별도 테스트 없음. 이 항목은 evidence 기록 단계다.
### 중간 검증
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: Edge, shared config, generated proto package가 모두 PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `packages/go/config/config.go` | REFRESH_CONTRACT-1 |
| `packages/go/config/config_test.go` | REFRESH_CONTRACT-1, REFRESH_CONTRACT-2 |
| `apps/edge/internal/configrefresh/*.go` | REFRESH_CONTRACT-2 |
| `apps/edge/internal/configrefresh/*_test.go` | REFRESH_CONTRACT-2 |
| `apps/edge/internal/bootstrap/runtime.go` | REFRESH_CONTRACT-1 |
| `apps/edge/internal/bootstrap/refresh_admin.go` | REFRESH_CONTRACT-1, REFRESH_CONTRACT-2 |
| `apps/edge/internal/bootstrap/runtime_test.go` | REFRESH_CONTRACT-1 |
| `apps/edge/internal/edgecmd/config.go` | REFRESH_CONTRACT-1, REFRESH_CONTRACT-2 |
| `apps/edge/internal/edgecmd/edgecmd_test.go` | REFRESH_CONTRACT-1 |
| `apps/edge/cmd/edge/main.go` | REFRESH_CONTRACT-1 |
| `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/CODE_REVIEW-cloud-G07.md` | REFRESH_CONTRACT-3 |
## 최종 검증
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: 모든 Edge/config/proto generated package 테스트 PASS.
```bash
go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config
```
예상: CLI entrypoint, running admin API, refresh config schema targeted tests PASS.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,161 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=1 tag=REVIEW_REFRESH_CONTRACT -->
# Plan - REVIEW_REFRESH_CONTRACT
## 이 파일을 읽는 구현 에이전트에게
이 계획은 이전 `REFRESH_CONTRACT` 구현의 코드리뷰 FAIL 항목만 해결한다. 구현 뒤에는 반드시 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운다. review finalization, 로그 rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이다.
선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션에 정확한 연결 근거를 남기고 멈춘다. 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이번 FAIL은 repo 내부 구현/검증으로 해소 가능한 항목이므로 기본적으로 사용자 리뷰 요청 대상이 아니다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log`
- Archived review: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- Refresh candidate loading uses only `config.LoadEdge` while `config check` applies `validateEdgeConfig`, so invalid adapter/provider configs can be accepted by refresh.
- Refresh apply mutates runtime config/model catalog without a thread-safe runtime/service snapshot boundary.
- Classifier reports only provider capacity/queue fields and misses planned model catalog/provider structural diffs.
- New `refresh` config contract is absent from `configs/edge.yaml` and hostsetup Edge templates.
- Affected files:
- `apps/edge/internal/configrefresh/*.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/refresh_admin.go`
- `apps/edge/internal/service/*.go`
- `apps/edge/internal/edgecmd/config.go`
- `apps/edge/internal/edgecmd/edgecmd_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `packages/go/hostsetup/templates.go`
- `packages/go/hostsetup/setup_test.go`
- `configs/edge.yaml`
- Verification evidence from prior loop:
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` passed in the archived review.
- `go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./packages/go/config` passed in the archived review.
- Allowed narrow archive reread:
- Read only the two archived files listed above if exact prior context is needed.
## 구현 체크리스트
- [ ] [REVIEW_REFRESH_CONTRACT-1] Refresh candidate validation is shared with `config check`, and invalid adapter/provider configs are rejected through `/refresh`.
- [ ] [REVIEW_REFRESH_CONTRACT-2] Refresh apply uses a thread-safe runtime/service snapshot boundary with deterministic concurrency coverage.
- [ ] [REVIEW_REFRESH_CONTRACT-3] Config diff classification reports model catalog/provider structural changes instead of silently returning `no changes detected`.
- [ ] [REVIEW_REFRESH_CONTRACT-4] The new `refresh` config contract is represented in user-facing Edge config examples/templates and covered by tests.
- [ ] [REVIEW_REFRESH_CONTRACT-5] Final verification commands are executed and the review stub records actual stdout/stderr.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## REVIEW_REFRESH_CONTRACT-1 - Shared Refresh Validation
### 문제
`configrefresh.LoadCandidate` only calls `config.LoadEdge`, while `iop-edge config check` additionally calls `validateEdgeConfig`. This violates the plan's same-validator requirement and S09 invalid provider config evidence.
### 해결 방법
Move the Edge runtime validation currently in `apps/edge/internal/edgecmd/config.go` into a non-CLI internal package or helper that both `config check` and `configrefresh.LoadCandidate` can call without an import cycle. Keep CLI command behavior unchanged.
### 수정 파일 및 체크리스트
- [ ] Shared validator is introduced outside the CLI command path.
- [ ] `config check` uses the shared validator.
- [ ] refresh candidate loading uses the shared validator.
- [ ] Add a refresh rejection test for at least one config that `validateEdgeConfig` currently rejects, such as enabled `vllm` or `openai_compat` with an empty endpoint.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/edgecmd ./packages/go/config
```
## REVIEW_REFRESH_CONTRACT-2 - Thread-Safe Runtime Snapshot
### 문제
Refresh apply mutates `Runtime.Cfg` and `Service.modelCatalog` while request dispatch can read provider and catalog data. The current locks do not protect the shared backing arrays or service catalog reads.
### 해결 방법
Use a single safe runtime state boundary. Either make this contract phase classify/report only and defer mutation to the dependent apply plan, or implement copy-on-write replacement plus service/node-store locking so dispatch sees one consistent immutable snapshot. Do not mutate provider slices that are shared with `NodeStore` without the same synchronization used by dispatch/status readers.
### 수정 파일 및 체크리스트
- [ ] Runtime refresh reads a stable current snapshot.
- [ ] Apply does not race with provider pool dispatch, status snapshots, or model catalog reads.
- [ ] Tests cover apply during concurrent service reads or otherwise prove mutation is not performed in this phase.
- [ ] Targeted race verification is recorded.
### 중간 검증
```bash
go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
```
## REVIEW_REFRESH_CONTRACT-3 - Complete Diff Classification
### 문제
The classifier only reports capacity/max_queue/queue_timeout changes. Planned mutable model catalog changes and unsupported provider structural changes can be hidden as `applied` with an empty `changes` list.
### 해결 방법
Extend `Classify` to compare the planned diff surface:
- `models[].providers` and model catalog entry changes should produce `applied` changes when valid and mutable.
- provider add/remove, adapter/type/category/models/health changes, and any unsupported node/provider structure changes should be classified explicitly as `restart_required` or `rejected`, depending on the chosen contract for this phase.
- mixed mutable and immutable changes must not be silently partial-applied.
### 수정 파일 및 체크리스트
- [ ] Model catalog/provider mapping changes are present in `Result.Changes`.
- [ ] Provider add/remove or unsupported provider field changes are not reported as `no changes detected`.
- [ ] Tests assert the exact status and at least one concrete changed path for these cases.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh
```
## REVIEW_REFRESH_CONTRACT-4 - Config Examples And Templates
### 문제
`packages/go/config.EdgeConfig` now has a `refresh` section, but neither the tracked `configs/edge.yaml` example nor hostsetup Edge templates show it. Operators cannot discover how to enable the local admin endpoint required by `iop-edge config refresh`.
### 해결 방법
Add a conservative default `refresh` block to `configs/edge.yaml`, the setup-installed Edge template, and the bundle-local `config init` template. Keep `enabled: false` by default and document the loopback listen address. Update hostsetup template tests.
### 수정 파일 및 체크리스트
- [ ] `configs/edge.yaml` includes `refresh.enabled` and `refresh.listen`.
- [ ] `packages/go/hostsetup/templates.go` Edge templates include the same default refresh block.
- [ ] hostsetup/config tests cover the template/default consistency.
### 중간 검증
```bash
go test -count=1 ./packages/go/hostsetup ./packages/go/config
```
## 최종 검증
```bash
go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
```
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: targeted race check와 Edge/config/proto generated package 테스트가 모두 PASS.

View file

@ -0,0 +1,133 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=3 tag=REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT -->
# Plan - REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT
## 이 파일을 읽는 구현 에이전트에게
이 계획은 이전 `REVIEW_REVIEW_REFRESH_CONTRACT` 구현의 코드리뷰 FAIL 항목만 해결한다. 구현 뒤에는 반드시 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운다. review finalization, 로그 rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이다.
선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션에 정확한 연결 근거를 남기고 멈춘다. 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이번 FAIL은 repo 내부 구현/검증으로 해소 가능한 항목이므로 기본적으로 사용자 리뷰 요청 대상이 아니다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_local_G05_2.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_local_G05_2.log`
- Verdict: FAIL
- Required summary:
- `configrefresh.applyRuntimeNormalization` cleans explicitly configured relative `logging.path` values with `filepath.Clean`.
- `iop-edge serve` does not clean explicit log paths; `edgecmd.ResolveEdgeLogPath` returns non-empty explicit values unchanged.
- Therefore source-identical YAML with `logging.path: "./logs/edge.log"` can still be classified as `restart_required` against the running process when only provider capacity changed.
- The new regression tests cover empty `logging.path`, but not explicit relative `logging.path`, and the test helper duplicates the same clean behavior.
- Prior loop context:
- Earlier archives: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_1.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_1.log`.
- Current loop verification commands all passed, but they do not cover explicit relative log paths.
- Affected files:
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/configrefresh/classify_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go` if runtime-level helper duplication is adjusted
- `apps/edge/internal/edgecmd/paths.go` or a new import-safe helper location if normalization is shared
- Verification evidence from current review:
- `go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd` passed.
- `go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service` passed.
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` passed.
- Allowed narrow archive reread:
- Read only the six archive files listed above if exact prior-loop context is needed.
## 구현 체크리스트
- [x] [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-1] Refresh candidate logging path normalization exactly matches `iop-edge serve` for empty, absolute, and explicit relative `logging.path`.
- [x] [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-2] Regression tests cover explicit relative `logging.path` with capacity-only refresh and prove no `logging` restart-required diff is produced.
- [x] [REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-3] Final verification commands are executed and the review stub records actual stdout/stderr.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-1 - Exact Serve Logging Path Contract
### 문제
`edgecmd.ResolveEdgeLogPath` keeps explicit values unchanged:
```go
if explicit != "" {
return explicit, nil
}
```
`configrefresh.applyRuntimeNormalization` currently calls `filepath.Clean` for explicit relative paths. That changes source-level values such as `./logs/edge.log` to `logs/edge.log`, while a running `serve` process keeps `./logs/edge.log`. Since `Classify` treats `logging` as restart-required, a capacity-only refresh can be blocked by this false diff.
### 해결 방법
Make the refresh candidate path normalization exactly match the effective `serve` behavior:
- empty `logging.path`: resolve to `<binary-dir>/logs/edge.log`,
- absolute `logging.path`: preserve exactly,
- explicit relative `logging.path`: preserve exactly, including `./` segments.
Prefer moving the existing effective-config normalization into an import-safe helper used by both `apps/edge/cmd/edge/main.go` and `configrefresh.LoadCandidate`. If that is too broad for this follow-up, keep the implementation minimal but add comments/tests that lock the exact equivalence.
Do not remove `logging` from restart-required classification. Real effective logging changes must still produce `restart_required`.
### 수정 파일 및 체크리스트
- [x] Remove the `filepath.Clean` behavior for explicit relative `logging.path` in refresh normalization, or replace duplicated code with a shared helper that preserves explicit values like `serve`.
- [x] Keep existing empty-path normalization behavior aligned with `edgecmd.ResolveEdgeLogPath`.
- [x] Keep `bootstrap.artifact_dir` relative path behavior aligned with `edgecmd.ResolveBootstrapArtifactDir`.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/edgecmd
```
## REVIEW_REVIEW_REVIEW_REFRESH_CONTRACT-2 - Explicit Relative Path Regression
### 문제
The current tests prove empty `logging.path` works but do not catch explicit relative path drift. Their test helper also cleans relative paths, mirroring the buggy refresh behavior rather than `serve`.
### 해결 방법
Add a focused regression test that fails before the fix:
- current config mimics `serve` with `logging.path: "./logs/edge.log"` preserved exactly,
- candidate YAML uses the same `logging.path: "./logs/edge.log"` and changes only provider capacity,
- dry-run result is `applied`,
- result changes contain the provider capacity diff,
- no `logging` restart-required diff appears.
If using a helper to build serve-normalized current config, make the helper preserve explicit relative log paths exactly.
### 수정 파일 및 체크리스트
- [x] Add or update a configrefresh test for explicit relative `logging.path`.
- [x] Update any test helper so it models `edgecmd.ResolveEdgeLogPath` exactly for explicit values.
- [x] Existing empty logging path regression remains covered.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
```
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd
```
```bash
go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
```
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: explicit relative logging path 회귀 테스트, targeted race check, full Edge/config/proto tests가 모두 PASS.

View file

@ -0,0 +1,131 @@
<!-- task=m-runtime-reconnect-config-refresh/02_refresh_contract plan=2 tag=REVIEW_REVIEW_REFRESH_CONTRACT -->
# Plan - REVIEW_REVIEW_REFRESH_CONTRACT
## 이 파일을 읽는 구현 에이전트에게
이 계획은 이전 `REVIEW_REFRESH_CONTRACT` 구현의 코드리뷰 FAIL 항목만 해결한다. 구현 뒤에는 반드시 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운다. review finalization, 로그 rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이다.
선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션에 정확한 연결 근거를 남기고 멈춘다. 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이번 FAIL은 repo 내부 구현/검증으로 해소 가능한 항목이므로 기본적으로 사용자 리뷰 요청 대상이 아니다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `entrypoint`: refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- `diff-classify`: config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_1.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_1.log`
- Verdict: FAIL
- Required summary:
- `iop-edge serve` normalizes runtime config after load: `logging.path` is resolved with `edgecmd.ResolveEdgeLogPath`, and `bootstrap.artifact_dir` is resolved with `edgecmd.ResolveBootstrapArtifactDir`.
- `configrefresh.LoadCandidate` loads and validates the candidate but does not apply the same runtime normalization.
- `configrefresh.Classify` compares `bootstrap.artifact_dir` and full `logging` as `restart_required`, so source-level identical YAML can be classified as restart-required against a running serve-normalized process.
- Existing running-runtime tests build `currentCfg` through `config.LoadEdge` directly, so they miss the real `serve` path mismatch.
- Prior loop context:
- Earlier FAIL archive: `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/plan_cloud_G07_0.log`, `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/code_review_cloud_G07_0.log`.
- The current loop fixed shared validation, thread-safe snapshots, model/provider diff classification, refresh templates, and final verification evidence.
- Affected files:
- `apps/edge/cmd/edge/main.go`
- `apps/edge/internal/edgecmd/paths.go`
- `apps/edge/internal/configrefresh/classify.go`
- `apps/edge/internal/configrefresh/classify_test.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- Verification evidence from current review:
- `go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service` passed.
- `go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...` passed.
- These commands are not sufficient to close the issue because no test covers a serve-normalized current config against a source-level refresh candidate.
- Allowed narrow archive reread:
- Read only the four archive files listed above if exact prior-loop context is needed.
## 구현 체크리스트
- [ ] [REVIEW_REVIEW_REFRESH_CONTRACT-1] Refresh classification uses the same runtime-normalized config contract as `iop-edge serve` for `logging.path` and `bootstrap.artifact_dir`.
- [ ] [REVIEW_REVIEW_REFRESH_CONTRACT-2] Regression tests cover a serve-normalized current config plus source-level candidate YAML where only provider capacity changes and the result remains `applied`.
- [ ] [REVIEW_REVIEW_REFRESH_CONTRACT-3] Final verification commands are executed and the review stub records actual stdout/stderr.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## REVIEW_REVIEW_REFRESH_CONTRACT-1 - Runtime-Normalized Refresh Candidate
### 문제
`apps/edge/cmd/edge/main.go` loads `edge.yaml` and then mutates the runtime config into effective values:
```go
cfg.Logging.Path = logPath
cfg.Bootstrap.ArtifactDir = edgecmd.ResolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir)
```
Refresh candidates are loaded through `configrefresh.LoadCandidate`, which currently calls only `config.LoadEdge` and shared validation. The classifier then compares `bootstrap.artifact_dir` and full `logging` as restart-required fields. In a real process started with `iop-edge serve`, a candidate file that is source-level identical except for provider capacity can be rejected as `restart_required` because the current runtime holds normalized path values and the candidate does not.
### 해결 방법
Introduce one shared runtime normalization boundary and use it consistently for serve and refresh. Prefer a small helper in an import-safe internal package that:
- resolves `logging.path` with the existing `edgecmd.ResolveEdgeLogPath` behavior,
- resolves `bootstrap.artifact_dir` with the existing `edgecmd.ResolveBootstrapArtifactDir` behavior,
- is called by `loadRuntimeConfig` and by refresh candidate loading before classification/apply.
If moving the helper would create an import cycle, keep the public behavior equivalent and document why the chosen package boundary is safe. Do not weaken the classifier by ignoring `logging` or `bootstrap.artifact_dir`; they should still be restart-required when their effective values actually change.
### 수정 파일 및 체크리스트
- [ ] Add or expose an import-safe helper for Edge runtime config normalization.
- [ ] `iop-edge serve` continues to use the same effective config normalization.
- [ ] `configrefresh.LoadCandidate` or the classification path normalizes candidates before diffing.
- [ ] Capacity-only candidate refresh no longer reports `logging` or `bootstrap.artifact_dir` as restart-required when the source config did not change.
### 중간 검증
```bash
go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/configrefresh ./apps/edge/internal/edgecmd
```
## REVIEW_REVIEW_REFRESH_CONTRACT-2 - Serve-Path Regression Coverage
### 문제
Current tests load the running config with `config.LoadEdge` directly, which bypasses the `loadRuntimeConfig` normalization used by `serve`. This lets the bug hide behind matched unnormalized test fixtures.
### 해결 방법
Add focused regression coverage for the real mismatch:
- Build or simulate a current runtime config with serve-equivalent normalized values for `logging.path` and `bootstrap.artifact_dir`.
- Load a candidate YAML that uses source-level defaults or relative paths and changes only provider capacity.
- Assert dry-run classification is `applied`, includes the capacity change, and does not include `logging` or `bootstrap.artifact_dir` restart-required changes.
- When practical, also assert apply followed by a second dry-run produces no diff for the same candidate.
Keep the test deterministic; do not depend on a long-running external process or host-specific absolute paths beyond values produced by the shared helper.
### 수정 파일 및 체크리스트
- [ ] Add a classifier or runtime refresh test that fails on the archived bug.
- [ ] The test covers omitted `logging.path` or relative `bootstrap.artifact_dir` using the same source-level candidate YAML style operators will use.
- [ ] Existing S07/S08/S09 tests still pass and keep their intended statuses.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
```
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd
```
```bash
go test -race -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service
```
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: serve-normalized refresh regression, targeted race check, full Edge/config/proto tests가 모두 PASS.

View file

@ -0,0 +1,190 @@
<!-- task=m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply plan=0 tag=REFRESH_APPLY -->
# Code Review Reference - REFRESH_APPLY
> **[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 selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only Milestone lock decisions in `사용자 리뷰 요청` and stop for code-review.
> 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-21
task=m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply, plan=0, tag=REFRESH_APPLY
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `atomic-apply`: validation 또는 apply 실패 시 기존 routing/runtime 상태가 유지된다.
- `ops-report`: refresh 결과가 CLI/API 응답, 로그, event 중 최소 한 표면에 남고 변경된 node/provider/model 항목을 확인할 수 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G08.md` -> `code_review_cloud_G08_N.log`, `PLAN-cloud-G08.md` -> `plan_cloud_G08_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFRESH_APPLY-1] atomic runtime apply boundary | [ ] |
| [REFRESH_APPLY-2] runtime snapshot synchronization | [ ] |
| [REFRESH_APPLY-3] previous state regression | [ ] |
| [REFRESH_APPLY-4] ops report surface | [ ] |
| [REFRESH_APPLY-5] final evidence and review stub completion | [ ] |
## 구현 체크리스트
- [ ] [REFRESH_APPLY-1] active 또는 archived `02_refresh_contract` complete.log 존재를 확인하고, refresh apply를 staged validation -> commit 순서의 atomic boundary로 구현한다.
- [ ] [REFRESH_APPLY-2] Service/OpenAI/NodeStore runtime snapshot update를 동기화하고 provider capacity/queue policy refresh가 후속 dispatch에 반영되게 한다.
- [ ] [REFRESH_APPLY-3] invalid provider refresh 또는 apply failure 주입 후 기존 `/v1/models`와 provider dispatch 상태가 유지되는 regression test를 작성한다.
- [ ] [REFRESH_APPLY-4] refresh API/CLI 응답에 changed node/provider/model 항목과 status를 남기고 ops-report assertion을 추가한다.
- [ ] [REFRESH_APPLY-5] `atomic-apply``ops-report` 대상 최종 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G08_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G08_M.log`로 아카이브한다.
- [ ] `.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-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/``agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md``CODE_REVIEW-cloud-G08.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가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- active 또는 archived `02_refresh_contract/complete.log`가 실제로 존재하고, 02의 entrypoint/diff classifier가 PASS였는지 확인한다.
- rejected/restart_required/apply failure 경로에서 기존 `Runtime.Cfg`, `Service` state, OpenAI model catalog가 바뀌지 않는지 확인한다.
- provider capacity와 queue policy refresh가 기존 in-flight run을 잃지 않고 후속 dispatch에만 반영되는지 확인한다.
- S11 완료 근거가 API/CLI response 중 최소 한 표면에서 changed node/provider/model 항목을 검증하는지 확인한다.
- Node config push/proto/field smoke/docs 변경이 이 task에 섞이지 않았는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REFRESH_APPLY-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/...
(output)
```
### REFRESH_APPLY-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai
(output)
```
### REFRESH_APPLY-3 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service ./apps/edge/internal/openai
(output)
```
### REFRESH_APPLY-4 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap
(output)
```
### REFRESH_APPLY-5 중간 검증
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
(output)
```
### 최종 검증
```bash
$ find agent-task -path '*/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log' -print -quit
(output)
```
```bash
$ go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/edgecmd
(output)
```
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
(output)
```
---
> **[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.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan. |
| 검증 결과 | Implementing agent | Fill command output only; command changes require a `계획 대비 변경 사항` entry. |
| 코드리뷰 결과 | Review agent appends | Not included in stub. |

View file

@ -0,0 +1,450 @@
<!-- task=m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply plan=0 tag=REFRESH_APPLY -->
# Plan - REFRESH_APPLY
## 이 파일을 읽는 구현 에이전트에게
이 계획은 dependent split task다. active 또는 archived `02_refresh_contract/complete.log`가 확인되기 전에는 구현을 시작하지 않는다. 구현 후에는 반드시 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운다. review finalization, 로그 rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이다.
선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션을 정확한 연결 근거와 함께 채우고 멈춘다. 구현 중 사용자에게 직접 묻거나, chat 선택지를 제시하거나, `request_user_input`을 호출하거나, `USER_REVIEW.md`를 만들지 않는다. 환경/secret/service 준비, 일반 범위 조정, 검증 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과나 후속 계획으로 기록한다.
## 배경
`02_refresh_contract`는 running process entrypoint와 diff classification을 세운다. 이 plan은 그 계약 위에서 apply가 실패해도 기존 `/v1/models`, provider-pool dispatch, queue state가 손상되지 않는 atomic apply 경계를 만든다. 또한 operator가 확인할 수 있도록 refresh 결과에 changed node/provider/model 항목을 남긴다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 리뷰 요청은 선택된 Milestone 잠금 결정이 실구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 사용하며, 직접 사용자 prompt는 금지된다. code-review가 요청의 정당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `atomic-apply`: validation 또는 apply 실패 시 기존 routing/runtime 상태가 유지된다.
- `ops-report`: refresh 결과가 CLI/API 응답, 로그, event 중 최소 한 표면에 남고 변경된 node/provider/model 항목을 확인할 수 있다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `AGENTS.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md`
- `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.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`
- `agent-test/local/testing-smoke.md`
- `apps/edge/internal/edgecmd/config.go`
- `apps/edge/internal/edgecmd/root.go`
- `apps/edge/internal/edgecmd/edgecmd_test.go`
- `apps/edge/cmd/edge/main.go`
- `apps/edge/internal/bootstrap/module.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/artifact_server.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `apps/edge/internal/input/manager.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/routes.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/ollama_passthrough.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/run_result.go`
- `apps/edge/internal/openai/strict_output.go`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/status_provider.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/node_command.go`
- `apps/edge/internal/service/control_command.go`
- `apps/edge/internal/service/service_internal_test.go`
- `apps/edge/internal/service/status_provider_test.go`
- `apps/edge/internal/service/run_dispatch_internal_test.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/node/store.go`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/node/registry.go`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `proto/iop/runtime.proto`
- `configs/edge.yaml`
### SDD 기준
- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`, 상태 `[승인됨]`, SDD 잠금 `해제`.
- 대상 Acceptance Scenario:
- S10 -> `atomic-apply`: 기존 `/v1/models`와 provider routing이 정상일 때 일부 apply 실패 refresh 후에도 기존 상태가 유지되어야 한다.
- S11 -> `ops-report`: refresh가 `applied`, `rejected`, `restart_required` 중 하나로 끝나면 changed node/provider/model 항목과 status가 CLI/API/log/event 중 최소 하나에 남아야 한다.
- Evidence Map:
- S10: `agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply`, atomic apply failure regression test.
- S11: same task, CLI/API/log/event assertion for refresh report.
- 반영 방식: 구현 checklist는 apply staging/commit/rollback 경계, service/openai runtime snapshot 동기화, queue policy update, invalid refresh 후 `/v1/models`와 provider dispatch 유지, report surface 검증으로 구성한다.
### 테스트 환경 규칙
- `test_env=local`.
- `agent-test/local/rules.md`가 존재해 전체를 읽었다.
- 매칭 profile:
- `agent-test/local/edge-smoke.md`: Edge 단위 테스트 `go test ./apps/edge/...`; smoke/e2e는 field 환경 준비가 필요한 경우 별도 후속 evidence로 남긴다.
- `agent-test/local/platform-common-smoke.md`: config/proto 공통 확인 `go test ./packages/go/... ./proto/gen/...`; proto 변경 없음이 원칙이다.
- `agent-test/local/testing-smoke.md`: 테스트 규칙 자체는 수정하지 않는다.
- 적용 명령은 Go cache를 허용하지 않도록 `-count=1`을 사용한다. 실제 field capacity smoke는 refresh 에픽 이후 `field-refresh` task 범위라 이 plan의 PASS 조건에 넣지 않는다.
### 테스트 커버리지 공백
- Apply failure previous-state preservation: 기존 테스트 없음. 실패 주입 hook 또는 staged apply error로 기존 `/v1/models`와 dispatch 상태가 유지되는 regression test가 필요하다.
- Service state concurrency: `Service.SetNodeStore``SetModelCatalog`는 단순 setter라 동시 refresh/read 보호 테스트가 없다.
- OpenAI model catalog refresh: 기존 `TestModelsUsesConfiguredModelsOrTarget`는 fallback/configured models만 검증하고 provider-pool catalog refresh 후 `/v1/models` 변화를 검증하지 않는다.
- Queue policy refresh: `modelQueueManager.getOrCreateGroupLocked`는 기존 group policy를 유지하므로 provider `max_queue`/`queue_timeout_ms` refresh test가 없다.
- Ops report: CLI/API response에 changed node/provider/model 항목이 남는지 검증하는 테스트가 없다.
### 심볼 참조
- renamed/removed symbol 없음.
- 02 plan에서 추가될 것으로 기대되는 `Runtime.RefreshConfig`, `configrefresh.Result`, `configrefresh.Change`를 확장 사용한다. 02 구현명이 다르면 이 plan 구현 전 review/plan 변경 사항에 실제 symbol mapping을 기록한다.
### 분할 판단
- split decision policy를 plan 파일 선택 전에 평가했다.
- 공유 task group: `agent-task/m-runtime-reconnect-config-refresh`.
- sibling plan:
- `02_refresh_contract`: entrypoint와 diff classifier. 이 plan의 predecessor.
- `03+02_edge_refresh_apply`: atomic apply와 ops report. directory name이 `+02`를 선언한다.
- predecessor `02` completion:
- active lookup `agent-task/m-runtime-reconnect-config-refresh/02_*/complete.log`, `02+*/complete.log`: 없음.
- archive lookup `agent-task/archive/*/*/m-runtime-reconnect-config-refresh/02_*/complete.log`, `02+*/complete.log`: 없음.
- 현재 세션에서 `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract/PLAN-cloud-G07.md``CODE_REVIEW-cloud-G07.md`를 생성했지만 `complete.log`는 없다.
- 구현은 `02_refresh_contract` PASS 후 시작해야 한다. 02가 FAIL/WARN으로 follow-up plan을 만든 경우 그 follow-up까지 완료되어 active 또는 archived `02_*` complete.log가 생긴 뒤 진행한다.
### 범위 결정 근거
- Node config push/ack, generated proto, Node adapter diff/drain/runtime-config apply는 node-config 에픽이므로 제외한다.
- Field GX10/OneXPlayer capacity smoke와 docs 갱신은 field 에픽이므로 제외한다.
- 이번 plan은 Edge-local runtime state와 report surface만 다룬다. Control Plane command surface는 02 plan에서 선택한 MVP 표면이 아니면 추가하지 않는다.
### 빌드 등급
- `cloud-G08`: running Edge apply, shared runtime state, OpenAI route catalog, provider dispatch queue, rollback regression이 한꺼번에 걸린다. 02 contract에 의존하지만 실패 보존과 동시 read/write 안정성이 핵심이라 cloud 고난도 작업이다.
## 구현 체크리스트
- [ ] [REFRESH_APPLY-1] active 또는 archived `02_refresh_contract` complete.log 존재를 확인하고, refresh apply를 staged validation -> commit 순서의 atomic boundary로 구현한다.
- [ ] [REFRESH_APPLY-2] Service/OpenAI/NodeStore runtime snapshot update를 동기화하고 provider capacity/queue policy refresh가 후속 dispatch에 반영되게 한다.
- [ ] [REFRESH_APPLY-3] invalid provider refresh 또는 apply failure 주입 후 기존 `/v1/models`와 provider dispatch 상태가 유지되는 regression test를 작성한다.
- [ ] [REFRESH_APPLY-4] refresh API/CLI 응답에 changed node/provider/model 항목과 status를 남기고 ops-report assertion을 추가한다.
- [ ] [REFRESH_APPLY-5] `atomic-apply``ops-report` 대상 최종 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
1. `find agent-task -path '*/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log' -print -quit`가 active 또는 archived complete.log 경로를 출력하는지 확인한다. 출력이 없으면 구현을 시작하지 않는다.
2. 02 완료 로그의 Roadmap Completion이 `entrypoint`, `diff-classify`를 PASS로 확인했는지 읽고, 실제 symbol/API 이름이 이 plan의 기대와 다르면 `계획 대비 변경 사항`에 mapping을 기록한다.
3. 이 plan은 directory name상 predecessor가 `02` 하나뿐이다. 다른 숨은 선행 의존성을 추가하지 않는다.
## REFRESH_APPLY-1 - Atomic Runtime Apply Boundary
### 문제
현재 `Runtime`은 시작 시 config snapshot을 조립한 뒤 갱신 경계가 없다. `Service`의 node store와 model catalog도 단순 setter라 apply 중간 실패를 되돌릴 단위가 없다.
Before:
```go
// apps/edge/internal/service/service.go:38
func (s *Service) SetNodeStore(store *edgenode.NodeStore) {
s.nodeStore = store
if s.queue != nil {
s.queue.setStore(store)
}
}
// apps/edge/internal/service/service.go:47
func (s *Service) SetModelCatalog(catalog []config.ModelCatalogEntry) {
s.modelCatalog = catalog
}
```
Runtime 시작도 한 번만 주입한다.
```go
// apps/edge/internal/bootstrap/runtime.go:54
svc := edgeservice.New(registry, bus)
svc.SetNodeStore(nodeStore)
svc.SetModelCatalog(cfg.Models)
inputManager := edgeinput.NewManager(*cfg, svc, logger.Named("input"))
```
### 해결 방법
- `Runtime``refreshMu`를 추가해 concurrent refresh apply를 직렬화한다.
- apply는 새 config load/validation/classify, 새 `NodeStore`/model catalog staging, report 생성, commit 순서로 진행한다.
- commit 전까지 기존 `Runtime.Cfg`, `Service` state, `OpenAI` catalog를 변경하지 않는다.
- commit은 한 함수에서 수행하고, error 반환 가능한 단계는 commit 전에 끝낸다. 테스트용 failure injection hook은 commit 전에만 둔다.
- rejected/restart_required 결과는 state를 변경하지 않는다.
- apply 성공 후 `Runtime.Cfg` snapshot, `Service` state, `Input.OpenAI` catalog를 함께 갱신한다.
After shape:
```go
// apps/edge/internal/bootstrap/runtime.go
func (r *Runtime) ApplyRefresh(ctx context.Context, req configrefresh.Request) (configrefresh.Result, error) {
r.refreshMu.Lock()
defer r.refreshMu.Unlock()
staged, result, err := r.stageRefresh(ctx, req)
if err != nil || result.Status != configrefresh.StatusApplied {
return result, err
}
return r.commitRefresh(staged, result)
}
```
### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/bootstrap/runtime.go`: `refreshMu`, staged apply, commit helper 추가.
- [ ] `apps/edge/internal/bootstrap/refresh_admin.go`: apply mode가 atomic apply helper를 호출하게 조정.
- [ ] `apps/edge/internal/configrefresh/*.go`: result/report 구조가 staged apply에 필요한 changed item metadata를 담도록 확장.
- [ ] `apps/edge/internal/node/store.go`: 필요 시 immutable replacement/clone helper 추가.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: rejected/restart_required가 state를 바꾸지 않는 테스트 추가.
### 테스트 작성
- 작성: `apps/edge/internal/bootstrap/runtime_test.go``TestRefreshApplyDoesNotMutateOnRejectedCandidate`, `TestRefreshApplyDoesNotMutateOnRestartRequired`.
- 작성: failure hook을 둔다면 `TestRefreshApplyFailurePreservesRuntimeSnapshot`.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/...
```
예상: refresh apply boundary와 기존 Edge package 테스트 PASS.
## REFRESH_APPLY-2 - Runtime Snapshot Synchronization
### 문제
OpenAI `/v1/models`와 route dispatch는 `modelCatalog`를 직접 읽고, provider-pool dispatch는 `Service.modelCatalog``Service.nodeStore`를 직접 읽는다. refresh apply가 이 값들을 바꾸면 data race 또는 서로 다른 snapshot이 생길 수 있다.
Before:
```go
// apps/edge/internal/openai/routes.go:28
var modelIDs []string
if len(s.modelCatalog) > 0 {
for _, entry := range s.modelCatalog {
```
```go
// apps/edge/internal/openai/chat_handler.go:203
for i := range s.modelCatalog {
if s.modelCatalog[i].ID == model {
return &s.modelCatalog[i]
}
}
```
```go
// apps/edge/internal/service/run_dispatch.go:493
for i := range s.modelCatalog {
if s.modelCatalog[i].ID == req.ModelGroupKey {
catalogEntry = &s.modelCatalog[i]
```
### 해결 방법
- `Service``runtimeMu sync.RWMutex`를 추가하고 nodeStore/modelCatalog read/write를 helper로 감싼다.
- `OpenAI Server``catalogMu sync.RWMutex` 또는 atomic snapshot helper를 추가해 `/v1/models``findProviderPoolEntry`가 복사본을 읽게 한다.
- `SetModelCatalog`는 slice copy를 저장하고, read helper는 copy 또는 immutable pointer를 반환한다.
- `modelQueueManager.setStore` 호출은 `Service` commit helper에서 같은 write lock 아래 수행한다.
- `modelQueueManager.getOrCreateGroupLocked`의 기존 group policy retention을 검토하고, provider `max_queue`/`queue_timeout_ms` 변경이 기존 group에도 반영되도록 `updateGroupPolicyLocked` 또는 model group reset policy를 추가한다. In-flight run map은 유지한다.
Before queue behavior:
```go
// apps/edge/internal/service/model_queue.go:140
// getOrCreateGroupLocked returns an existing group or creates one with the
// given policy (applying defaults for zero fields). Policy is only applied
// at creation; existing groups retain their current policy.
func (m *modelQueueManager) getOrCreateGroupLocked(key string, policy groupPolicy) *modelQueueGroup {
```
### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/service/service.go`: runtime state lock/helpers 추가.
- [ ] `apps/edge/internal/service/run_dispatch.go`: model catalog/node store read helper 사용.
- [ ] `apps/edge/internal/service/status_provider.go`: node store snapshot read helper 사용.
- [ ] `apps/edge/internal/service/model_queue.go`: store/policy refresh helper 추가.
- [ ] `apps/edge/internal/service/*_test.go`: capacity/queue policy refresh dispatch test 추가.
- [ ] `apps/edge/internal/openai/server.go`: catalog snapshot lock 추가.
- [ ] `apps/edge/internal/openai/routes.go`: `/v1/models`가 refreshed catalog snapshot을 사용.
- [ ] `apps/edge/internal/openai/chat_handler.go`: provider-pool route lookup이 refreshed catalog snapshot을 사용.
- [ ] `apps/edge/internal/openai/server_test.go`: refreshed catalog가 `/v1/models`와 provider-pool route dispatch에 반영되는 테스트 추가.
### 테스트 작성
- 작성: `TestRefreshProviderCapacityAffectsNextDispatch`.
- 작성: `TestRefreshProviderQueuePolicyUpdatesExistingGroup`.
- 작성: `TestOpenAIModelsUsesRefreshedCatalog`.
- 작성: `TestOpenAIProviderPoolDispatchUsesRefreshedCatalog`.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai
```
예상: service/openai targeted refresh state tests PASS.
## REFRESH_APPLY-3 - Previous State Regression
### 문제
SDD S10은 invalid provider refresh가 기존 `/v1/models`와 provider dispatch 상태를 깨지 않아야 한다고 요구한다. 현재는 refresh apply 경계가 없어서 이 회귀를 증명하는 테스트도 없다.
### 해결 방법
- 정상 current config로 runtime을 만들고 provider-pool catalog가 `/v1/models`에 보이는 상태를 준비한다.
- 기존 provider dispatch가 성공하는 net.Pipe 기반 service fixture를 구성한다.
- invalid provider config candidate로 apply를 호출한다.
- 결과가 `rejected`이고, 직후 `/v1/models`가 기존 catalog를 반환하며, 같은 model group dispatch가 기존 provider/capacity 기준으로 성공하는지 검증한다.
### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: end-to-end rejected refresh preservation test 추가.
- [ ] `apps/edge/internal/service/service_test.go` 또는 새 refresh test: provider dispatch preservation helper 추가.
- [ ] `apps/edge/internal/openai/server_test.go`: `/v1/models` preservation assertion 추가.
### 테스트 작성
- 작성: `TestRefreshRejectedProviderPreservesModelsAndDispatch`.
- 작성: 실패 hook 사용 시 `TestRefreshApplyHookFailurePreservesModelsAndDispatch`.
- invalid candidate는 provider capacity 음수, duplicate provider id, 또는 catalog가 존재하지 않는 provider를 참조하는 fixture 중 하나를 사용한다.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service ./apps/edge/internal/openai
```
예상: S10 회귀 테스트 PASS.
## REFRESH_APPLY-4 - Ops Report Surface
### 문제
refresh 결과는 operator가 바뀐 node/provider/model 항목과 최종 status를 확인할 수 있어야 한다. `02_refresh_contract`가 classifier 응답을 제공하더라도 S11 완료를 위해 API/CLI/log/event 중 최소 한 표면에 changed items가 검증되어야 한다.
### 해결 방법
- MVP report surface는 API/CLI JSON response로 고정한다.
- `configrefresh.Result``changed_nodes`, `changed_providers`, `changed_models`, `restart_required_paths`, `rejected_reason`을 추가하거나 기존 `changes[]`에서 stable derived fields를 노출한다.
- CLI는 API response를 그대로 pretty JSON 또는 stable JSON으로 출력한다. 테스트 안정성을 위해 key order가 필요한 assertion은 decode 후 field 비교로 한다.
- Runtime logger에는 `request_id`, `mode`, `status`, `changed_counts`를 남긴다. Log assertion은 필수 완료 근거가 아니며 API/CLI assertion을 S11 근거로 삼는다.
### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/configrefresh/*.go`: report fields 추가.
- [ ] `apps/edge/internal/bootstrap/refresh_admin.go`: response에 report fields 포함.
- [ ] `apps/edge/internal/edgecmd/config.go`: CLI 출력에 report fields 유지.
- [ ] `apps/edge/internal/edgecmd/edgecmd_test.go`: changed node/provider/model response assertion 추가.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: API response report assertion 추가.
### 테스트 작성
- 작성: `TestRefreshAPIReportIncludesChangedItems`.
- 작성: `TestConfigRefreshCommandPrintsChangedItems`.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/edgecmd ./apps/edge/internal/bootstrap
```
예상: S11 API/CLI report assertions PASS.
## REFRESH_APPLY-5 - Final Evidence
### 문제
`atomic-apply``ops-report`는 코드 결과만으로는 완료 처리되지 않는다. 구현 에이전트가 review stub에 실제 검증 출력과 설계 결정을 남겨야 code-review가 Roadmap Completion 근거를 만들 수 있다.
### 해결 방법
- 최종 검증 명령을 모두 실행한다.
- `CODE_REVIEW-cloud-G08.md`의 구현 항목별 완료 여부, 구현 체크리스트, 계획 대비 변경 사항, 주요 설계 결정, 검증 결과를 실제 내용으로 채운다.
- 02 plan과 API symbol이 달라진 경우 mapping을 기록한다.
### 수정 파일 및 체크리스트
- [ ] `agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/CODE_REVIEW-cloud-G08.md`: 구현 에이전트 소유 섹션 작성.
### 테스트 작성
- 별도 테스트 없음. 이 항목은 evidence 기록 단계다.
### 중간 검증
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: Edge, shared config, generated proto package가 모두 PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/bootstrap/runtime.go` | REFRESH_APPLY-1, REFRESH_APPLY-3 |
| `apps/edge/internal/bootstrap/refresh_admin.go` | REFRESH_APPLY-1, REFRESH_APPLY-4 |
| `apps/edge/internal/bootstrap/runtime_test.go` | REFRESH_APPLY-1, REFRESH_APPLY-3, REFRESH_APPLY-4 |
| `apps/edge/internal/configrefresh/*.go` | REFRESH_APPLY-1, REFRESH_APPLY-4 |
| `apps/edge/internal/node/store.go` | REFRESH_APPLY-1 |
| `apps/edge/internal/service/service.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/service/run_dispatch.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/service/status_provider.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/service/model_queue.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/service/*_test.go` | REFRESH_APPLY-2, REFRESH_APPLY-3 |
| `apps/edge/internal/openai/server.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/openai/routes.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/openai/chat_handler.go` | REFRESH_APPLY-2 |
| `apps/edge/internal/openai/server_test.go` | REFRESH_APPLY-2, REFRESH_APPLY-3 |
| `apps/edge/internal/edgecmd/config.go` | REFRESH_APPLY-4 |
| `apps/edge/internal/edgecmd/edgecmd_test.go` | REFRESH_APPLY-4 |
| `agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply/CODE_REVIEW-cloud-G08.md` | REFRESH_APPLY-5 |
## 최종 검증
```bash
find agent-task -path '*/m-runtime-reconnect-config-refresh/02_refresh_contract/complete.log' -print -quit
```
예상: active 또는 archived 02 predecessor complete.log 경로가 한 줄 출력된다.
```bash
go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/edgecmd
```
예상: atomic apply, OpenAI model surface, provider dispatch, CLI/API report targeted tests PASS.
```bash
go test -count=1 ./apps/edge/... ./packages/go/... ./proto/gen/...
```
예상: 모든 Edge/config/proto generated package 테스트 PASS.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,114 @@
package bootstrap
import (
"context"
"encoding/json"
"net"
"net/http"
"time"
"go.uber.org/zap"
"iop/apps/edge/internal/configrefresh"
)
// RefreshAdminServer is the Edge-local HTTP admin server for config refresh.
// It listens on a loopback address and accepts POST /refresh requests from
// the local CLI (iop-edge config refresh). The server is only started when
// refresh.enabled=true in the Edge config.
type RefreshAdminServer struct {
listen string
runtime *Runtime
logger *zap.Logger
server *http.Server
ln net.Listener
}
func newRefreshAdminServer(listen string, rt *Runtime, logger *zap.Logger) *RefreshAdminServer {
return &RefreshAdminServer{
listen: listen,
runtime: rt,
logger: logger,
}
}
func (s *RefreshAdminServer) Start(_ context.Context) error {
ln, err := net.Listen("tcp", s.listen)
if err != nil {
return err
}
s.ln = ln
mux := http.NewServeMux()
mux.HandleFunc("/refresh", s.handleRefresh)
s.server = &http.Server{
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
go func() {
if err := s.server.Serve(ln); err != nil && err != http.ErrServerClosed {
s.logger.Warn("refresh admin server exited", zap.Error(err))
}
}()
s.logger.Info("refresh admin server listening", zap.String("addr", ln.Addr().String()))
return nil
}
func (s *RefreshAdminServer) Stop(ctx context.Context) error {
if s.server == nil {
return nil
}
return s.server.Shutdown(ctx)
}
// Addr returns the actual bound address. Useful for port-0 tests.
func (s *RefreshAdminServer) Addr() string {
if s.ln == nil {
return ""
}
return s.ln.Addr().String()
}
func (s *RefreshAdminServer) handleRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req configrefresh.Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, configrefresh.Result{
Status: configrefresh.StatusRejected,
Mode: req.Mode,
Summary: "invalid request body: " + err.Error(),
Changes: []configrefresh.Change{},
})
return
}
result, err := s.runtime.RefreshConfig(r.Context(), req)
if err != nil {
writeJSON(w, http.StatusInternalServerError, configrefresh.Result{
Status: configrefresh.StatusRejected,
Mode: req.Mode,
RequestID: req.RequestID,
Summary: "refresh error: " + err.Error(),
Changes: []configrefresh.Change{},
})
return
}
httpStatus := http.StatusOK
if result.Status == configrefresh.StatusRejected {
httpStatus = http.StatusUnprocessableEntity
}
writeJSON(w, httpStatus, result)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}

View file

@ -8,6 +8,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"iop/apps/edge/internal/configrefresh"
edgecontrolplane "iop/apps/edge/internal/controlplane" edgecontrolplane "iop/apps/edge/internal/controlplane"
edgeevents "iop/apps/edge/internal/events" edgeevents "iop/apps/edge/internal/events"
edgeinput "iop/apps/edge/internal/input" edgeinput "iop/apps/edge/internal/input"
@ -34,7 +35,10 @@ type Runtime struct {
Input *edgeinput.Manager Input *edgeinput.Manager
Artifact *ArtifactServer Artifact *ArtifactServer
ControlPlane *edgecontrolplane.Connector ControlPlane *edgecontrolplane.Connector
RefreshAdmin *RefreshAdminServer
refreshMu sync.Mutex
cfgMu sync.RWMutex
lifetimeMu sync.Mutex lifetimeMu sync.Mutex
lifetimeCancel context.CancelFunc lifetimeCancel context.CancelFunc
} }
@ -72,7 +76,7 @@ func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) {
edgecontrolplane.WithNodeEventBus(bus), edgecontrolplane.WithNodeEventBus(bus),
) )
return &Runtime{ rt := &Runtime{
Cfg: cfg, Cfg: cfg,
Logger: logger, Logger: logger,
Registry: registry, Registry: registry,
@ -83,7 +87,9 @@ func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) {
Input: inputManager, Input: inputManager,
Artifact: artifactServer, Artifact: artifactServer,
ControlPlane: connector, ControlPlane: connector,
}, nil }
rt.RefreshAdmin = newRefreshAdminServer(cfg.Refresh.Listen, rt, logger.Named("refresh-admin"))
return rt, nil
} }
func (r *Runtime) wireHandlers() { func (r *Runtime) wireHandlers() {
@ -124,6 +130,16 @@ func (r *Runtime) Start(ctx context.Context) error {
_ = r.Server.Stop() _ = r.Server.Stop()
return err return err
} }
if r.Cfg.Refresh.Enabled {
if err := r.RefreshAdmin.Start(lifetimeCtx); err != nil {
r.cancelLifetime()
r.ControlPlane.Stop()
_ = r.Artifact.Stop(context.Background())
_ = r.Input.Stop(context.Background())
_ = r.Server.Stop()
return err
}
}
r.startMetrics() r.startMetrics()
return nil return nil
} }
@ -133,10 +149,14 @@ func (r *Runtime) Stop() error {
r.ControlPlane.Stop() r.ControlPlane.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
refreshErr := r.RefreshAdmin.Stop(ctx)
inputErr := r.Input.Stop(ctx) inputErr := r.Input.Stop(ctx)
artifactErr := r.Artifact.Stop(ctx) artifactErr := r.Artifact.Stop(ctx)
err := r.Server.Stop() err := r.Server.Stop()
_ = r.Logger.Sync() _ = r.Logger.Sync()
if err == nil {
err = refreshErr
}
if err == nil { if err == nil {
err = inputErr err = inputErr
} }
@ -146,6 +166,52 @@ func (r *Runtime) Stop() error {
return err return err
} }
// RefreshConfig evaluates a config refresh request against the current runtime
// config. In apply mode, mutable-only changes are committed to the runtime.
func (r *Runtime) RefreshConfig(ctx context.Context, req configrefresh.Request) (configrefresh.Result, error) {
r.refreshMu.Lock()
defer r.refreshMu.Unlock()
r.cfgMu.RLock()
current := r.Cfg
r.cfgMu.RUnlock()
result, candidate, err := configrefresh.Evaluate(ctx, current, req)
if err != nil {
return result, err
}
if req.Mode == configrefresh.ModeApply && result.Status == configrefresh.StatusApplied && candidate != nil {
if err := r.applyMutableConfig(candidate); err != nil {
result.Status = configrefresh.StatusRejected
result.Summary = "apply candidate config: " + err.Error()
if result.Changes == nil {
result.Changes = []configrefresh.Change{}
}
}
}
return result, nil
}
// applyMutableConfig commits a validated mutable-only candidate via copy-on-write
// replacement. Readers observe either the previous runtime snapshot or the new
// one, and validation/build failures leave the previous snapshot untouched.
func (r *Runtime) applyMutableConfig(candidate *config.EdgeConfig) error {
nextStore, err := edgenode.LoadFromConfig(candidate.Nodes)
if err != nil {
return err
}
r.cfgMu.Lock()
r.Cfg = candidate
r.NodeStore = nextStore
r.cfgMu.Unlock()
r.Server.SetNodeStore(nextStore)
r.Service.SetRuntimeConfig(nextStore, candidate.Models)
r.Input.SetModelCatalog(candidate.Models)
return nil
}
func (r *Runtime) newLifetimeContext() context.Context { func (r *Runtime) newLifetimeContext() context.Context {
r.lifetimeMu.Lock() r.lifetimeMu.Lock()
defer r.lifetimeMu.Unlock() defer r.lifetimeMu.Unlock()

View file

@ -1,15 +1,20 @@
package bootstrap package bootstrap
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt"
"net" "net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
"iop/apps/edge/internal/configrefresh"
"iop/packages/go/config" "iop/packages/go/config"
) )
@ -167,6 +172,352 @@ func TestNewRuntimeRejectsDuplicateToken(t *testing.T) {
} }
} }
// TestRefreshAdminDryRunAndApplyReachRunningRuntime verifies S06: a running
// Edge process can receive dry-run and apply refresh requests via the admin API
// and returns mode-specific results.
func TestRefreshAdminDryRunAndApplyReachRunningRuntime(t *testing.T) {
dir := t.TempDir()
serverAddr := freeTCPAddr(t)
// Write base and candidate YAMLs with explicit immutable field values so
// that config.LoadEdge produces configs with identical defaults on both sides.
// Only capacity differs between base and candidate.
baseYAML := fmt.Sprintf(`
server:
listen: %q
bootstrap:
listen: "0.0.0.0:18080"
artifact_dir: "artifacts"
logging:
level: "error"
refresh:
enabled: true
listen: "127.0.0.1:0"
openai:
listen: "0.0.0.0:18081"
a2a:
listen: "0.0.0.0:8081"
metrics:
port: 0
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
max_queue: 4
queue_timeout_ms: 5000
`, serverAddr)
candidateYAML := fmt.Sprintf(`
server:
listen: %q
bootstrap:
listen: "0.0.0.0:18080"
artifact_dir: "artifacts"
logging:
level: "error"
refresh:
enabled: true
listen: "127.0.0.1:0"
openai:
listen: "0.0.0.0:18081"
a2a:
listen: "0.0.0.0:8081"
metrics:
port: 0
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 8
max_queue: 4
queue_timeout_ms: 5000
`, serverAddr)
basePath := filepath.Join(dir, "base.yaml")
if err := os.WriteFile(basePath, []byte(baseYAML), 0o600); err != nil {
t.Fatalf("write base: %v", err)
}
candidatePath := filepath.Join(dir, "candidate.yaml")
if err := os.WriteFile(candidatePath, []byte(candidateYAML), 0o600); err != nil {
t.Fatalf("write candidate: %v", err)
}
currentCfg, err := config.LoadEdge(basePath)
if err != nil {
t.Fatalf("load base config: %v", err)
}
// Apply serve-equivalent normalization so the current config matches
// what iop-edge serve would hold after loadRuntimeConfig.
currentCfg.Logging.Path = serveNormalizedLogPath()
currentCfg.Bootstrap.ArtifactDir = serveNormalizedArtifactDir(currentCfg.Bootstrap.ArtifactDir)
// Override fields that don't need to match between runtime and candidate.
currentCfg.Refresh.Enabled = true
currentCfg.Refresh.Listen = "127.0.0.1:0"
currentCfg.Logging.Level = "error"
rt, err := NewRuntime(currentCfg)
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
ctx := context.Background()
if err := rt.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer func() {
if err := rt.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
}()
adminAddr := rt.RefreshAdmin.Addr()
if adminAddr == "" {
t.Fatal("refresh admin addr is empty after Start")
}
adminURL := "http://" + adminAddr + "/refresh"
client := &http.Client{Timeout: 5 * time.Second}
postRefresh := func(t *testing.T, mode configrefresh.Mode) configrefresh.Result {
t.Helper()
body, _ := json.Marshal(configrefresh.Request{
Mode: mode,
ConfigPath: candidatePath,
RequestID: string(mode) + "-req",
})
resp, err := client.Post(adminURL, "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST /refresh mode=%s: %v", mode, err)
}
defer resp.Body.Close()
var result configrefresh.Result
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("decode response: %v", err)
}
return result
}
// dry-run: process is reached, result has mode=dry_run.
dryResult := postRefresh(t, configrefresh.ModeDryRun)
if dryResult.Mode != configrefresh.ModeDryRun {
t.Errorf("dry-run: expected mode=%q, got %q", configrefresh.ModeDryRun, dryResult.Mode)
}
if dryResult.Status != configrefresh.StatusApplied {
t.Errorf("dry-run: expected status=%q (capacity change), got %q", configrefresh.StatusApplied, dryResult.Status)
}
// apply: process is reached, result has mode=apply and status=applied.
applyResult := postRefresh(t, configrefresh.ModeApply)
if applyResult.Mode != configrefresh.ModeApply {
t.Errorf("apply: expected mode=%q, got %q", configrefresh.ModeApply, applyResult.Mode)
}
if applyResult.Status != configrefresh.StatusApplied {
t.Errorf("apply: expected status=%q, got %q", configrefresh.StatusApplied, applyResult.Status)
}
// verify dry-run and apply have distinct observable results: the apply
// committed the capacity change, so the next dry-run shows no changes.
dryResult2 := postRefresh(t, configrefresh.ModeDryRun)
if len(dryResult2.Changes) != 0 {
t.Errorf("after apply, expected no diff changes on second dry-run, got %+v", dryResult2.Changes)
}
}
func serveNormalizedLogPath() string {
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), "logs", "edge.log")
}
if wd, err := os.Getwd(); err == nil {
return filepath.Join(wd, "logs", "edge.log")
}
return "logs/edge.log"
}
func serveNormalizedArtifactDir(dir string) string {
if dir == "" {
dir = "artifacts"
}
if filepath.IsAbs(dir) {
return dir
}
bd := binaryDirForTest()
return filepath.Join(bd, dir)
}
func binaryDirForTest() string {
if exe, err := os.Executable(); err == nil {
return filepath.Dir(exe)
}
if wd, err := os.Getwd(); err == nil {
return wd
}
return "."
}
func TestRefreshApplyConcurrentRuntimeReaders(t *testing.T) {
dir := t.TempDir()
serverAddr := freeTCPAddr(t)
openAIAddr := freeTCPAddr(t)
writeConfig := func(t *testing.T, name string, capacity int, displayName string) string {
t.Helper()
path := filepath.Join(dir, name)
yaml := fmt.Sprintf(`
server:
listen: %q
bootstrap:
listen: "0.0.0.0:18080"
artifact_dir: "artifacts"
logging:
level: "error"
refresh:
enabled: false
listen: "127.0.0.1:19093"
openai:
enabled: true
listen: %q
adapter: "openai_compat"
target: ""
a2a:
listen: "0.0.0.0:8081"
metrics:
port: 0
models:
- id: "qwen3.6:35b"
display_name: %q
providers:
prov-a: "served-qwen"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
openai_compat_instances:
- name: "vllm-gpu"
enabled: true
provider: "vllm"
endpoint: "http://127.0.0.1:8000/v1"
providers:
- id: "prov-a"
type: "vllm"
category: "api"
adapter: "vllm-gpu"
models: ["served-qwen"]
health: "available"
capacity: %d
max_queue: 4
queue_timeout_ms: 5000
`, serverAddr, openAIAddr, displayName, capacity)
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return path
}
basePath := writeConfig(t, "base.yaml", 2, "Qwen Base")
candidatePath := writeConfig(t, "candidate.yaml", 8, "Qwen Candidate")
currentCfg, err := config.LoadEdge(basePath)
if err != nil {
t.Fatalf("load base config: %v", err)
}
// Apply serve-equivalent normalization.
currentCfg.Logging.Path = serveNormalizedLogPath()
currentCfg.Bootstrap.ArtifactDir = serveNormalizedArtifactDir(currentCfg.Bootstrap.ArtifactDir)
rt, err := NewRuntime(currentCfg)
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
if err := rt.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
defer func() {
if err := rt.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
}()
client := &http.Client{Timeout: 5 * time.Second}
start := make(chan struct{})
var wg sync.WaitGroup
errCh := make(chan error, 3)
wg.Add(1)
go func() {
defer wg.Done()
<-start
for i := 0; i < 50; i++ {
result, err := rt.RefreshConfig(context.Background(), configrefresh.Request{
Mode: configrefresh.ModeApply,
ConfigPath: candidatePath,
RequestID: fmt.Sprintf("apply-%d", i),
})
if err != nil {
errCh <- err
return
}
if result.Status != configrefresh.StatusApplied {
errCh <- fmt.Errorf("refresh status=%s summary=%s changes=%+v", result.Status, result.Summary, result.Changes)
return
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
<-start
for i := 0; i < 100; i++ {
_ = rt.Service.ListNodeSnapshots()
}
}()
wg.Add(1)
go func() {
defer wg.Done()
<-start
url := "http://" + openAIAddr + "/v1/models"
for i := 0; i < 100; i++ {
resp, err := client.Get(url)
if err != nil {
errCh <- err
return
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errCh <- fmt.Errorf("GET /v1/models status=%d", resp.StatusCode)
return
}
}
}()
close(start)
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("concurrent refresh reader failed: %v", err)
}
}
}
func freeTCPAddr(t *testing.T) string { func freeTCPAddr(t *testing.T) string {
t.Helper() t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0") ln, err := net.Listen("tcp", "127.0.0.1:0")

View file

@ -0,0 +1,387 @@
package configrefresh
import (
"context"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"iop/apps/edge/internal/edgevalidate"
"iop/packages/go/config"
)
// applyRuntimeNormalization normalizes candidate config so that classification
// uses the same effective values as a running serve process.
func applyRuntimeNormalization(cfg *config.EdgeConfig) {
// Normalize logging.path must match edgecmd.ResolveEdgeLogPath exactly:
// - empty → resolve to binary-dir/logs/edge.log
// - absolute → preserve exactly
// - relative → preserve exactly (include ./ segments)
// Serve keeps explicit relative paths like "./logs/edge.log" unchanged,
// so classifying against a cleaned value produces false restart_required.
if cfg.Logging.Path == "" {
cfg.Logging.Path = defaultEdgeLogPath()
}
// NOTE: explicit absolute / relative paths are intentionally left untouched.
// Normalize bootstrap.artifact_dir resolve relative paths to absolute.
cfg.Bootstrap.ArtifactDir = resolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir)
}
func defaultEdgeLogPath() string {
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), "logs", "edge.log")
}
if wd, err := os.Getwd(); err == nil {
return filepath.Join(wd, "logs", "edge.log")
}
return "logs/edge.log"
}
func resolveBootstrapArtifactDir(dir string) string {
if dir == "" {
dir = "artifacts"
}
if filepath.IsAbs(dir) {
return dir
}
return filepath.Join(binaryDir(), dir)
}
func binaryDir() string {
if exe, err := os.Executable(); err == nil {
return filepath.Dir(exe)
}
if wd, err := os.Getwd(); err == nil {
return wd
}
return "."
}
// LoadCandidate loads and validates an EdgeConfig from the given path, then
// applies the same runtime normalization that iop-edge serve uses. This
// ensures that classification compares effective (normalized) values and
// prevents false restart_required results when source-level YAML is
// identical but paths are relative or empty.
func LoadCandidate(path string) (*config.EdgeConfig, error) {
cfg, err := config.LoadEdge(path)
if err != nil {
return nil, err
}
if err := edgevalidate.ValidateEdgeConfig(cfg); err != nil {
return nil, err
}
applyRuntimeNormalization(cfg)
return cfg, nil
}
// providerKey identifies a provider entry for diffing.
type providerKey struct {
NodeKey string
Type string
Category config.Category
Adapter string
Models []string
Health string
Capacity int
MaxQueue int
QueueTimeoutMS int
LifecycleCapabilities []string
}
func buildProviderIndex(cfg *config.EdgeConfig) map[string]providerKey {
idx := make(map[string]providerKey)
for i, node := range cfg.Nodes {
nodeKey := nodeIdentity(node, i)
for _, p := range node.Providers {
idx[p.ID] = providerKey{
NodeKey: nodeKey,
Type: p.Type,
Category: p.Category,
Adapter: p.Adapter,
Models: append([]string(nil), p.Models...),
Health: p.Health,
Capacity: p.Capacity,
MaxQueue: p.MaxQueue,
QueueTimeoutMS: p.QueueTimeoutMS,
LifecycleCapabilities: append([]string(nil), p.LifecycleCapabilities...),
}
}
}
return idx
}
type nodeKey struct {
Alias string
Token string
AgentKind string
Adapters config.AdaptersConf
Runtime config.RuntimeConf
}
func buildNodeIndex(cfg *config.EdgeConfig) map[string]nodeKey {
idx := make(map[string]nodeKey, len(cfg.Nodes))
for i, node := range cfg.Nodes {
idx[nodeIdentity(node, i)] = nodeKey{
Alias: node.Alias,
Token: node.Token,
AgentKind: node.AgentKind,
Adapters: node.Adapters,
Runtime: node.Runtime,
}
}
return idx
}
func nodeIdentity(node config.NodeDefinition, index int) string {
if node.ID != "" {
return node.ID
}
if node.Alias != "" {
return node.Alias
}
return fmt.Sprintf("#%d", index)
}
func buildModelIndex(cfg *config.EdgeConfig) map[string]config.ModelCatalogEntry {
idx := make(map[string]config.ModelCatalogEntry, len(cfg.Models))
for _, model := range cfg.Models {
idx[model.ID] = model
}
return idx
}
func appendIfChanged[T comparable](changes *[]Change, path string, class Status, previous, next T) {
if previous == next {
return
}
*changes = append(*changes, Change{
Path: path,
Class: class,
Previous: fmt.Sprint(previous),
Next: fmt.Sprint(next),
})
}
func appendDeepIfChanged(changes *[]Change, path string, class Status, previous, next interface{}) {
if reflect.DeepEqual(previous, next) {
return
}
*changes = append(*changes, Change{
Path: path,
Class: class,
Previous: fmt.Sprintf("%v", previous),
Next: fmt.Sprintf("%v", next),
})
}
// Classify computes the diff between current and candidate configs and returns
// the classified change list and an overall status.
func Classify(current, candidate *config.EdgeConfig) Result {
var changes []Change
// --- restart_required fields ---
appendIfChanged(&changes, "edge.id", StatusRestartRequired, current.Edge.ID, candidate.Edge.ID)
appendIfChanged(&changes, "edge.name", StatusRestartRequired, current.Edge.Name, candidate.Edge.Name)
appendIfChanged(&changes, "server.listen", StatusRestartRequired, current.Server.Listen, candidate.Server.Listen)
appendIfChanged(&changes, "server.advertise_host", StatusRestartRequired, current.Server.AdvertiseHost, candidate.Server.AdvertiseHost)
appendIfChanged(&changes, "bootstrap.listen", StatusRestartRequired, current.Bootstrap.Listen, candidate.Bootstrap.Listen)
appendIfChanged(&changes, "bootstrap.artifact_base_url", StatusRestartRequired, current.Bootstrap.ArtifactBaseURL, candidate.Bootstrap.ArtifactBaseURL)
appendIfChanged(&changes, "bootstrap.artifact_dir", StatusRestartRequired, current.Bootstrap.ArtifactDir, candidate.Bootstrap.ArtifactDir)
appendDeepIfChanged(&changes, "tls", StatusRestartRequired, current.TLS, candidate.TLS)
appendDeepIfChanged(&changes, "logging", StatusRestartRequired, current.Logging, candidate.Logging)
appendIfChanged(&changes, "metrics.port", StatusRestartRequired, current.Metrics.Port, candidate.Metrics.Port)
appendDeepIfChanged(&changes, "console", StatusRestartRequired, current.Console, candidate.Console)
appendDeepIfChanged(&changes, "control_plane", StatusRestartRequired, current.ControlPlane, candidate.ControlPlane)
appendDeepIfChanged(&changes, "refresh", StatusRestartRequired, current.Refresh, candidate.Refresh)
appendDeepIfChanged(&changes, "openai", StatusRestartRequired, current.OpenAI, candidate.OpenAI)
appendDeepIfChanged(&changes, "a2a", StatusRestartRequired, current.A2A, candidate.A2A)
currentNodes := buildNodeIndex(current)
candidateNodes := buildNodeIndex(candidate)
for key, cur := range currentNodes {
next, exists := candidateNodes[key]
if !exists {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[%q]", key),
Class: StatusRestartRequired,
Previous: "present",
Next: "absent",
})
continue
}
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].alias", key), StatusRestartRequired, cur.Alias, next.Alias)
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].token", key), StatusRestartRequired, cur.Token, next.Token)
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].agent_kind", key), StatusRestartRequired, cur.AgentKind, next.AgentKind)
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[%q].adapters", key), StatusRestartRequired, cur.Adapters, next.Adapters)
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[%q].runtime", key), StatusRestartRequired, cur.Runtime, next.Runtime)
}
for key := range candidateNodes {
if _, exists := currentNodes[key]; !exists {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[%q]", key),
Class: StatusRestartRequired,
Previous: "absent",
Next: "present",
})
}
}
// --- mutable applied fields: provider capacity ---
currentProviders := buildProviderIndex(current)
candidateProviders := buildProviderIndex(candidate)
for provID, cur := range currentProviders {
next, exists := candidateProviders[provID]
if !exists {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[].providers[%q]", provID),
Class: StatusRestartRequired,
Previous: "present",
Next: "absent",
})
continue
}
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].node", provID), StatusRestartRequired, cur.NodeKey, next.NodeKey)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].type", provID), StatusRestartRequired, cur.Type, next.Type)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].category", provID), StatusRestartRequired, cur.Category, next.Category)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].adapter", provID), StatusRestartRequired, cur.Adapter, next.Adapter)
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].models", provID), StatusRestartRequired, cur.Models, next.Models)
appendIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].health", provID), StatusRestartRequired, cur.Health, next.Health)
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[].providers[%q].lifecycle_capabilities", provID), StatusRestartRequired, cur.LifecycleCapabilities, next.LifecycleCapabilities)
}
for provID := range candidateProviders {
if _, exists := currentProviders[provID]; !exists {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[].providers[%q]", provID),
Class: StatusRestartRequired,
Previous: "absent",
Next: "present",
})
}
}
for provID, cp := range candidateProviders {
cur, exists := currentProviders[provID]
if !exists {
continue
}
if cur.Capacity != cp.Capacity {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[].providers[%q].capacity", provID),
Class: StatusApplied,
Previous: fmt.Sprintf("%d", cur.Capacity),
Next: fmt.Sprintf("%d", cp.Capacity),
})
}
if cur.MaxQueue != cp.MaxQueue {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[].providers[%q].max_queue", provID),
Class: StatusApplied,
Previous: fmt.Sprintf("%d", cur.MaxQueue),
Next: fmt.Sprintf("%d", cp.MaxQueue),
})
}
if cur.QueueTimeoutMS != cp.QueueTimeoutMS {
changes = append(changes, Change{
Path: fmt.Sprintf("nodes[].providers[%q].queue_timeout_ms", provID),
Class: StatusApplied,
Previous: fmt.Sprintf("%d", cur.QueueTimeoutMS),
Next: fmt.Sprintf("%d", cp.QueueTimeoutMS),
})
}
}
currentModels := buildModelIndex(current)
candidateModels := buildModelIndex(candidate)
for modelID, cur := range currentModels {
next, exists := candidateModels[modelID]
if !exists {
changes = append(changes, Change{
Path: fmt.Sprintf("models[%q]", modelID),
Class: StatusApplied,
Previous: "present",
Next: "absent",
})
continue
}
appendIfChanged(&changes, fmt.Sprintf("models[%q].display_name", modelID), StatusApplied, cur.DisplayName, next.DisplayName)
appendDeepIfChanged(&changes, fmt.Sprintf("models[%q].providers", modelID), StatusApplied, cur.Providers, next.Providers)
}
for modelID := range candidateModels {
if _, exists := currentModels[modelID]; !exists {
changes = append(changes, Change{
Path: fmt.Sprintf("models[%q]", modelID),
Class: StatusApplied,
Previous: "absent",
Next: "present",
})
}
}
sort.SliceStable(changes, func(i, j int) bool {
if changes[i].Path == changes[j].Path {
return changes[i].Class < changes[j].Class
}
return changes[i].Path < changes[j].Path
})
// Determine overall status: any restart_required wins over applied.
hasRestart := false
for _, c := range changes {
if c.Class == StatusRestartRequired {
hasRestart = true
break
}
}
overallStatus := StatusApplied
summary := "no changes detected"
if hasRestart {
overallStatus = StatusRestartRequired
summary = "some changes require a process restart"
} else if len(changes) > 0 {
summary = "all changes can be applied without restart"
}
return Result{
Status: overallStatus,
Changes: changes,
Summary: summary,
}
}
// Evaluate loads the candidate config from req.ConfigPath, validates it, and
// classifies changes against current. On success it also returns the loaded
// candidate so the caller can apply mutable changes in apply mode.
func Evaluate(_ context.Context, current *config.EdgeConfig, req Request) (Result, *config.EdgeConfig, error) {
if req.Mode != ModeDryRun && req.Mode != ModeApply {
return Result{
Status: StatusRejected,
Mode: req.Mode,
RequestID: req.RequestID,
Summary: fmt.Sprintf("invalid mode %q: must be dry_run or apply", req.Mode),
Changes: []Change{},
}, nil, nil
}
candidate, err := LoadCandidate(req.ConfigPath)
if err != nil {
return Result{
Status: StatusRejected,
Mode: req.Mode,
RequestID: req.RequestID,
Summary: fmt.Sprintf("load candidate config: %v", err),
Changes: []Change{},
}, nil, nil
}
result := Classify(current, candidate)
result.Mode = req.Mode
result.RequestID = req.RequestID
if result.Changes == nil {
result.Changes = []Change{}
}
return result, candidate, nil
}

View file

@ -0,0 +1,712 @@
package configrefresh_test
import (
"context"
"os"
"path/filepath"
"testing"
"iop/apps/edge/internal/configrefresh"
"iop/packages/go/config"
)
func writeYAML(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return path
}
// buildNormalizedCurrent creates a current config that mimics what a running
// serve process holds after loadRuntimeConfig normalization. Empty logging
// paths are resolved, explicit logging paths are preserved, and bootstrap
// artifact_dir is resolved to the effective runtime path.
func buildNormalizedCurrent(t *testing.T, path string) *config.EdgeConfig {
t.Helper()
cfg, err := config.LoadEdge(path)
if err != nil {
t.Fatalf("load current: %v", err)
}
// Apply the same normalization that loadRuntimeConfig performs.
// NOTE: explicit relative logging.path is preserved exactly to match
// edgecmd.ResolveEdgeLogPath only empty paths are resolved.
if cfg.Logging.Path == "" {
cfg.Logging.Path = _normalizedLogPath()
}
// Explicit absolute / relative paths are intentionally left untouched.
cfg.Bootstrap.ArtifactDir = _normalizeArtifactDir(cfg.Bootstrap.ArtifactDir)
return cfg
}
func _normalizedLogPath() string {
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), "logs", "edge.log")
}
if wd, err := os.Getwd(); err == nil {
return filepath.Join(wd, "logs", "edge.log")
}
return "logs/edge.log"
}
func _normalizeArtifactDir(dir string) string {
if dir == "" {
dir = "artifacts"
}
if filepath.IsAbs(dir) {
return dir
}
return filepath.Join(binaryDirForTesting(), dir)
}
func binaryDirForTesting() string {
if exe, err := os.Executable(); err == nil {
return filepath.Dir(exe)
}
if wd, err := os.Getwd(); err == nil {
return wd
}
return "."
}
const baseEdgeYAML = `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
max_queue: 4
queue_timeout_ms: 5000
`
// TestClassifyProviderCapacityApplied verifies S07: provider capacity change is classified as applied.
func TestClassifyProviderCapacityApplied(t *testing.T) {
dir := t.TempDir()
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
candidateYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 8
max_queue: 4
queue_timeout_ms: 5000
`
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
current := buildNormalizedCurrent(t, currentPath)
ctx := context.Background()
result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-s07",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if result.Status != configrefresh.StatusApplied {
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary)
}
if len(result.Changes) == 0 {
t.Fatalf("expected at least one change")
}
found := false
for _, c := range result.Changes {
if c.Class == configrefresh.StatusApplied {
found = true
}
if c.Class == configrefresh.StatusRestartRequired {
t.Errorf("unexpected restart_required change: %s", c.Path)
}
}
if !found {
t.Errorf("no applied change found in: %+v", result.Changes)
}
}
// TestClassifyListenPortRestartRequired verifies S08: listen port change is classified as restart_required.
func TestClassifyListenPortRestartRequired(t *testing.T) {
dir := t.TempDir()
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
candidateYAML := `
server:
listen: "0.0.0.0:19090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
max_queue: 4
queue_timeout_ms: 5000
`
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
current := buildNormalizedCurrent(t, currentPath)
ctx := context.Background()
result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-s08",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if result.Status != configrefresh.StatusRestartRequired {
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusRestartRequired, result.Status, result.Summary)
}
found := false
for _, c := range result.Changes {
if c.Path == "server.listen" && c.Class == configrefresh.StatusRestartRequired {
found = true
}
}
if !found {
t.Errorf("server.listen restart_required change not found in: %+v", result.Changes)
}
}
// TestRefreshInvalidYAMLRejected verifies S09: invalid YAML is classified as rejected.
func TestRefreshInvalidYAMLRejected(t *testing.T) {
dir := t.TempDir()
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
invalidPath := writeYAML(t, dir, "invalid.yaml", "nodes: [\n - bad: [unterminated")
current := buildNormalizedCurrent(t, currentPath)
ctx := context.Background()
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: invalidPath,
RequestID: "test-s09-yaml",
})
if err != nil {
t.Fatalf("Evaluate should not return error for rejected config: %v", err)
}
if result.Status != configrefresh.StatusRejected {
t.Errorf("expected status=%q, got %q", configrefresh.StatusRejected, result.Status)
}
if candidate != nil {
t.Errorf("expected nil candidate for rejected result")
}
}
func TestClassifyModelCatalogProviderMappingApplied(t *testing.T) {
dir := t.TempDir()
currentYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
- id: "prov-b"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
models:
- id: "qwen3.6:35b"
display_name: "Qwen"
providers:
prov-a: "llama3.1"
`
candidateYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
- id: "prov-b"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
models:
- id: "qwen3.6:35b"
display_name: "Qwen"
providers:
prov-b: "llama3.1"
`
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
current := buildNormalizedCurrent(t, currentPath)
result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-model-catalog",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if result.Status != configrefresh.StatusApplied {
t.Fatalf("expected status=%q, got %q changes=%+v", configrefresh.StatusApplied, result.Status, result.Changes)
}
found := false
for _, change := range result.Changes {
if change.Path == `models["qwen3.6:35b"].providers` && change.Class == configrefresh.StatusApplied {
found = true
}
}
if !found {
t.Fatalf("model catalog providers change not found in %+v", result.Changes)
}
}
func TestClassifyProviderStructuralChangeRestartRequired(t *testing.T) {
dir := t.TempDir()
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
candidateYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "vllm"
category: "api"
models: ["llama3.1"]
capacity: 2
max_queue: 4
queue_timeout_ms: 5000
`
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
current := buildNormalizedCurrent(t, currentPath)
result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-provider-structural",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if result.Status != configrefresh.StatusRestartRequired {
t.Fatalf("expected status=%q, got %q changes=%+v", configrefresh.StatusRestartRequired, result.Status, result.Changes)
}
found := false
for _, change := range result.Changes {
if change.Path == `nodes[].providers["prov-a"].type` && change.Class == configrefresh.StatusRestartRequired {
found = true
}
}
if !found {
t.Fatalf("provider structural type change not found in %+v", result.Changes)
}
}
// TestRefreshInvalidProviderRejected verifies S09: invalid provider config is classified as rejected.
func TestRefreshInvalidProviderRejected(t *testing.T) {
dir := t.TempDir()
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
// Invalid: shared validator rejects enabled openai_compat without endpoint.
invalidProviderYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
openai_compat:
enabled: true
endpoint: ""
`
invalidPath := writeYAML(t, dir, "invalid_provider.yaml", invalidProviderYAML)
current := buildNormalizedCurrent(t, currentPath)
ctx := context.Background()
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: invalidPath,
RequestID: "test-s09-provider",
})
if err != nil {
t.Fatalf("Evaluate should not return error for rejected config: %v", err)
}
if result.Status != configrefresh.StatusRejected {
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusRejected, result.Status, result.Summary)
}
if candidate != nil {
t.Errorf("expected nil candidate for rejected result")
}
}
// TestClassifyServeNormalizedRefreshApplied verifies the archived bug:
// when current config is serve-normalized (logging.path absolute,
// bootstrap.artifact_dir absolute) and candidate uses source-level defaults
// (empty logging.path, relative bootstrap.artifact_dir) with only provider
// capacity changes, the result must be applied, NOT restart_required.
func TestClassifyServeNormalizedRefreshApplied(t *testing.T) {
dir := t.TempDir()
// Source-level YAML with empty logging.path and relative artifact_dir.
// Only capacity differs (2 → 8).
sourceYAML := `
server:
listen: "0.0.0.0:9090"
bootstrap:
artifact_dir: artifacts
logging:
level: info
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
max_queue: 4
queue_timeout_ms: 5000
`
// Candidate: only capacity changes to 8; logging/artifact_dir are same source-level defaults.
candidateYAML := `
server:
listen: "0.0.0.0:9090"
bootstrap:
artifact_dir: artifacts
logging:
level: info
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 8
max_queue: 4
queue_timeout_ms: 5000
`
currentPath := writeYAML(t, dir, "current.yaml", sourceYAML)
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
// Build current as serve-normalized: explicit absolute paths.
current := buildNormalizedCurrent(t, currentPath)
// Verify that the current config actually has normalized paths.
if current.Logging.Path == "" {
t.Fatalf("expected normalized logging.path, got empty")
}
if current.Bootstrap.ArtifactDir == "" {
t.Fatalf("expected normalized bootstrap.artifact_dir, got empty")
}
// Evaluate the candidate via LoadCandidate (which now applies normalization).
ctx := context.Background()
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-serve-normalized",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
// Key assertion: capacity-only refresh must NOT be restart_required.
if result.Status != configrefresh.StatusApplied {
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary)
}
// Ensure no restart_required changes for logging or bootstrap.artifact_dir.
for _, c := range result.Changes {
if c.Class == configrefresh.StatusRestartRequired {
t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next)
}
}
// Verify the capacity change is captured as applied.
foundCapacity := false
for _, c := range result.Changes {
if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied {
foundCapacity = true
if c.Previous != "2" || c.Next != "8" {
t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next)
}
}
}
if !foundCapacity {
t.Fatalf("capacity change not found in applied changes: %+v", result.Changes)
}
// Verify the candidate is also normalized.
if candidate == nil {
t.Fatalf("expected non-nil candidate")
}
if candidate.Logging.Path == "" {
t.Fatalf("expected normalized candidate logging.path, got empty")
}
}
// TestClassifyExplicitRelativeLoggingPathNoRestartRequired verifies that when
// both current and candidate use the same explicit relative logging.path
// (e.g. "./logs/edge.log") and only provider capacity differs, the refresh
// is classified as applied NOT restart_required.
//
// This is the regression test for the archived bug where applyRuntimeNormalization
// called filepath.Clean on explicit relative paths, producing a false diff
// against the serve-side value that preserves "./logs/edge.log" unchanged.
func TestClassifyExplicitRelativeLoggingPathNoRestartRequired(t *testing.T) {
dir := t.TempDir()
// Current config with explicit relative logging.path mimics what serve
// holds in memory after loading YAML with logging.path: "./logs/edge.log".
currentYAML := `
server:
listen: "0.0.0.0:9090"
logging:
path: "./logs/edge.log"
level: info
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
max_queue: 4
queue_timeout_ms: 5000
`
// Candidate: same explicit relative logging.path, only capacity changes.
candidateYAML := `
server:
listen: "0.0.0.0:9090"
logging:
path: "./logs/edge.log"
level: info
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 8
max_queue: 4
queue_timeout_ms: 5000
`
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
// buildNormalizedCurrent preserves the explicit relative logging.path.
current := buildNormalizedCurrent(t, currentPath)
// Verify the current config still has the explicit relative path.
if current.Logging.Path != "./logs/edge.log" {
t.Fatalf("expected current logging.path to remain './logs/edge.log', got %q", current.Logging.Path)
}
ctx := context.Background()
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-explicit-relative-logpath",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
// Key assertion: capacity-only refresh with same explicit relative path must be applied.
if result.Status != configrefresh.StatusApplied {
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary)
}
// Ensure no restart_required changes at all.
for _, c := range result.Changes {
if c.Class == configrefresh.StatusRestartRequired {
t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next)
}
}
// Verify the capacity change is captured as applied.
foundCapacity := false
for _, c := range result.Changes {
if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied {
foundCapacity = true
if c.Previous != "2" || c.Next != "8" {
t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next)
}
}
}
if !foundCapacity {
t.Fatalf("capacity change not found in applied changes: %+v", result.Changes)
}
// Candidate should also preserve the explicit relative path.
if candidate == nil {
t.Fatalf("expected non-nil candidate")
}
if candidate.Logging.Path != "./logs/edge.log" {
t.Errorf("expected candidate logging.path to remain './logs/edge.log', got %q", candidate.Logging.Path)
}
}
// TestClassifyDryRunTwiceNoDiff verifies that after apply, re-running dry-run
// on the same candidate produces no diff.
func TestClassifyDryRunTwiceNoDiff(t *testing.T) {
dir := t.TempDir()
baseYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
`
candidateYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
providers:
- id: "prov-a"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 4
`
currentPath := writeYAML(t, dir, "current.yaml", baseYAML)
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
current := buildNormalizedCurrent(t, currentPath)
ctx := context.Background()
// First dry-run: should detect capacity change.
result1, cand1, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-first-dryrun",
})
if err != nil {
t.Fatalf("first Evaluate: %v", err)
}
if result1.Status != configrefresh.StatusApplied {
t.Fatalf("first dry-run: expected status=%q, got %q", configrefresh.StatusApplied, result1.Status)
}
// Second dry-run with the same candidate loaded again.
// The candidate path still exists, so LoadCandidate re-reads the same YAML.
result2, cand2, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: candidatePath,
RequestID: "test-second-dryrun",
})
if err != nil {
t.Fatalf("second Evaluate: %v", err)
}
// Both results should be identical (same changes).
if len(result1.Changes) != len(result2.Changes) {
t.Fatalf("expected same number of changes, got %d vs %d: result1=%+v result2=%+v",
len(result1.Changes), len(result2.Changes), result1.Changes, result2.Changes)
}
// Both candidates should be normalized identically.
if cand1 == nil || cand2 == nil {
t.Fatalf("expected non-nil candidates")
}
if cand1.Logging.Path != cand2.Logging.Path {
t.Fatalf("candidate logging.path mismatch: %s vs %s", cand1.Logging.Path, cand2.Logging.Path)
}
}

View file

@ -0,0 +1,16 @@
package configrefresh
// Mode represents the refresh operation mode.
type Mode string
const (
ModeDryRun Mode = "dry_run"
ModeApply Mode = "apply"
)
// Request is the refresh operation request.
type Request struct {
Mode Mode `json:"mode"`
ConfigPath string `json:"config_path"`
RequestID string `json:"request_id,omitempty"`
}

View file

@ -0,0 +1,30 @@
package configrefresh
// Status is the overall classification of a refresh operation.
type Status string
const (
// StatusApplied means all detected changes are mutable and were (or would be) applied live.
StatusApplied Status = "applied"
// StatusRestartRequired means at least one change requires a process restart.
StatusRestartRequired Status = "restart_required"
// StatusRejected means the candidate config failed to load or validate.
StatusRejected Status = "rejected"
)
// Change describes one detected config diff and its classification.
type Change struct {
Path string `json:"path"`
Class Status `json:"class"`
Previous string `json:"previous,omitempty"`
Next string `json:"next,omitempty"`
}
// Result is the response from a refresh evaluate or apply operation.
type Result struct {
Status Status `json:"status"`
Mode Mode `json:"mode"`
RequestID string `json:"request_id,omitempty"`
Changes []Change `json:"changes"`
Summary string `json:"summary"`
}

View file

@ -1,15 +1,20 @@
package edgecmd package edgecmd
import ( import (
"bytes"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"iop/apps/edge/internal/node" "iop/apps/edge/internal/configrefresh"
"iop/apps/edge/internal/edgevalidate"
"iop/packages/go/config" "iop/packages/go/config"
"iop/packages/go/hostsetup" "iop/packages/go/hostsetup"
) )
@ -43,7 +48,7 @@ Typical local flow:
iop-edge node register node-silicon-ollama --adapter ollama iop-edge node register node-silicon-ollama --adapter ollama
iop-edge config check`, iop-edge config check`,
} }
c.AddCommand(configInitCmd(), configPrintCmd(), configCheckCmd()) c.AddCommand(configInitCmd(), configPrintCmd(), configCheckCmd(), configRefreshCmd())
return c return c
} }
@ -125,90 +130,64 @@ func configCheckCmd() *cobra.Command {
} }
} }
func anyOllamaEnabled(a config.AdaptersConf) bool { func configRefreshCmd() *cobra.Command {
if a.Ollama.Enabled { var mode string
return true var addr string
var cfgPath string
c := &cobra.Command{
Use: "refresh",
Short: "Send a config refresh request to a running Edge process",
Long: `Send a dry-run or apply config refresh request to the Edge-local admin API.
The running Edge process must have refresh.enabled=true in its config.`,
Example: ` iop-edge config refresh --mode dry-run
iop-edge config refresh --mode apply --config /path/to/edge.yaml`,
RunE: func(cmd *cobra.Command, _ []string) error {
var m configrefresh.Mode
switch mode {
case "dry-run", string(configrefresh.ModeDryRun):
m = configrefresh.ModeDryRun
case string(configrefresh.ModeApply):
m = configrefresh.ModeApply
default:
return fmt.Errorf("invalid --mode %q: must be dry-run or apply", mode)
}
// Resolve config path from flag or parent --config flag.
if cfgPath == "" {
resolved, err := ResolveConfigPath(cmd)
if err != nil {
return fmt.Errorf("resolve config path: %w", err)
}
cfgPath = resolved
}
req := configrefresh.Request{
Mode: m,
ConfigPath: cfgPath,
}
body, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post("http://"+addr+"/refresh", "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("POST http://%s/refresh: %w", addr, err)
}
defer resp.Body.Close()
var result configrefresh.Result
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decode response: %w", err)
}
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(result)
},
} }
for _, inst := range a.OllamaInstances { c.Flags().StringVar(&mode, "mode", "dry-run", "refresh mode: dry-run or apply")
if inst.Enabled { c.Flags().StringVar(&addr, "addr", "127.0.0.1:19093", "refresh admin server address")
return true c.Flags().StringVar(&cfgPath, "config-path", "", "candidate config path (defaults to the resolved --config path)")
} return c
}
return false
}
func anyVllmEnabled(a config.AdaptersConf) bool {
if a.Vllm.Enabled {
return true
}
for _, inst := range a.VllmInstances {
if inst.Enabled {
return true
}
}
return false
}
func anyOpenAICompatEnabled(a config.AdaptersConf) bool {
if a.OpenAICompat.Enabled {
return true
}
for _, inst := range a.OpenAICompatInstances {
if inst.Enabled {
return true
}
}
return false
} }
func validateEdgeConfig(cfg *config.EdgeConfig) error { func validateEdgeConfig(cfg *config.EdgeConfig) error {
_, err := node.LoadFromConfig(cfg.Nodes) return edgevalidate.ValidateEdgeConfig(cfg)
if err != nil {
return err
}
for i, n := range cfg.Nodes {
name := n.ID
if name == "" {
name = n.Alias
}
if name == "" {
name = fmt.Sprintf("index %d", i)
}
hasOllama := anyOllamaEnabled(n.Adapters)
hasVllm := anyVllmEnabled(n.Adapters)
hasOpenAICompat := anyOpenAICompatEnabled(n.Adapters)
if !hasOllama && !n.Adapters.CLI.Enabled && !hasVllm && !hasOpenAICompat {
return fmt.Errorf("node %q: at least one adapter must be enabled", name)
}
if n.Adapters.Ollama.Enabled && n.Adapters.Ollama.BaseURL == "" {
return fmt.Errorf("node %q: ollama adapter base_url must not be empty", name)
}
for j, inst := range n.Adapters.OllamaInstances {
if inst.Enabled && inst.BaseURL == "" {
return fmt.Errorf("node %q: ollama_instances[%d] %q: base_url must not be empty", name, j, inst.Name)
}
}
if n.Adapters.Vllm.Enabled && n.Adapters.Vllm.Endpoint == "" {
return fmt.Errorf("node %q: vllm adapter endpoint must not be empty", name)
}
for j, inst := range n.Adapters.VllmInstances {
if inst.Enabled && inst.Endpoint == "" {
return fmt.Errorf("node %q: vllm_instances[%d] %q: endpoint must not be empty", name, j, inst.Name)
}
}
if n.Adapters.OpenAICompat.Enabled && n.Adapters.OpenAICompat.Endpoint == "" {
return fmt.Errorf("node %q: openai_compat adapter endpoint must not be empty", name)
}
for j, inst := range n.Adapters.OpenAICompatInstances {
if inst.Enabled && inst.Endpoint == "" {
return fmt.Errorf("node %q: openai_compat_instances[%d] %q: endpoint must not be empty", name, j, inst.Name)
}
}
if n.Runtime.Concurrency < 0 {
return fmt.Errorf("node %q: runtime concurrency must be non-negative", name)
}
}
return nil
} }

View file

@ -2,6 +2,9 @@ package edgecmd
import ( import (
"bytes" "bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -9,6 +12,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"iop/apps/edge/internal/configrefresh"
"iop/packages/go/config" "iop/packages/go/config"
) )
@ -422,6 +426,83 @@ func TestValidateEdgeConfig_OpenAICompatInstanceEmptyEndpointRejected(t *testing
} }
} }
// TestConfigRefreshCommandPostsModeAndConfigPath verifies that the CLI refresh
// command sends a valid JSON request to the admin API and prints the JSON result.
func TestConfigRefreshCommandPostsModeAndConfigPath(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
writeMinimalEdgeYAML(t, cfgPath)
expectedResult := configrefresh.Result{
Status: configrefresh.StatusApplied,
Mode: configrefresh.ModeDryRun,
Summary: "no changes detected",
Changes: []configrefresh.Change{},
}
var capturedReq configrefresh.Request
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&capturedReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(expectedResult)
}))
defer ts.Close()
// Strip "http://" prefix for --addr flag.
addr := strings.TrimPrefix(ts.URL, "http://")
root := NewRoot(Options{})
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "config", "refresh",
"--mode", "dry-run",
"--addr", addr,
"--config-path", cfgPath,
})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v\n%s", err, out.String())
}
if capturedReq.Mode != configrefresh.ModeDryRun {
t.Errorf("expected mode=%q, got %q", configrefresh.ModeDryRun, capturedReq.Mode)
}
if capturedReq.ConfigPath != cfgPath {
t.Errorf("expected config_path=%q, got %q", cfgPath, capturedReq.ConfigPath)
}
if !strings.Contains(out.String(), string(configrefresh.StatusApplied)) {
t.Errorf("expected status in output; got:\n%s", out.String())
}
}
// TestConfigRefreshCommandRejectsInvalidMode verifies that an invalid --mode
// flag returns an error before sending any HTTP request.
func TestConfigRefreshCommandRejectsInvalidMode(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
writeMinimalEdgeYAML(t, cfgPath)
root := NewRoot(Options{})
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "config", "refresh",
"--mode", "invalid-mode",
"--addr", "127.0.0.1:0",
"--config-path", cfgPath,
})
err := root.Execute()
if err == nil {
t.Fatalf("expected error for invalid mode, got nil\n%s", out.String())
}
if !strings.Contains(err.Error(), "invalid --mode") {
t.Errorf("expected 'invalid --mode' in error, got: %v", err)
}
}
func TestValidateEdgeConfig_OpenAICompatLegacyEmptyEndpointRejected(t *testing.T) { func TestValidateEdgeConfig_OpenAICompatLegacyEmptyEndpointRejected(t *testing.T) {
cfg := &config.EdgeConfig{ cfg := &config.EdgeConfig{
Nodes: []config.NodeDefinition{ Nodes: []config.NodeDefinition{

View file

@ -0,0 +1,101 @@
package edgevalidate
import (
"fmt"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config"
)
// ValidateEdgeConfig validates Edge config fields that config.LoadEdge does
// not check: adapter endpoint presence, per-node adapter-enabled requirements,
// and node store consistency (token uniqueness). It is the shared validation
// path used by both `config check` and the refresh candidate loader so that
// the same ruleset applies regardless of the entry point.
func ValidateEdgeConfig(cfg *config.EdgeConfig) error {
_, err := edgenode.LoadFromConfig(cfg.Nodes)
if err != nil {
return err
}
for i, n := range cfg.Nodes {
name := n.ID
if name == "" {
name = n.Alias
}
if name == "" {
name = fmt.Sprintf("index %d", i)
}
hasOllama := anyOllamaEnabled(n.Adapters)
hasVllm := anyVllmEnabled(n.Adapters)
hasOpenAICompat := anyOpenAICompatEnabled(n.Adapters)
if !hasOllama && !n.Adapters.CLI.Enabled && !hasVllm && !hasOpenAICompat {
return fmt.Errorf("node %q: at least one adapter must be enabled", name)
}
if n.Adapters.Ollama.Enabled && n.Adapters.Ollama.BaseURL == "" {
return fmt.Errorf("node %q: ollama adapter base_url must not be empty", name)
}
for j, inst := range n.Adapters.OllamaInstances {
if inst.Enabled && inst.BaseURL == "" {
return fmt.Errorf("node %q: ollama_instances[%d] %q: base_url must not be empty", name, j, inst.Name)
}
}
if n.Adapters.Vllm.Enabled && n.Adapters.Vllm.Endpoint == "" {
return fmt.Errorf("node %q: vllm adapter endpoint must not be empty", name)
}
for j, inst := range n.Adapters.VllmInstances {
if inst.Enabled && inst.Endpoint == "" {
return fmt.Errorf("node %q: vllm_instances[%d] %q: endpoint must not be empty", name, j, inst.Name)
}
}
if n.Adapters.OpenAICompat.Enabled && n.Adapters.OpenAICompat.Endpoint == "" {
return fmt.Errorf("node %q: openai_compat adapter endpoint must not be empty", name)
}
for j, inst := range n.Adapters.OpenAICompatInstances {
if inst.Enabled && inst.Endpoint == "" {
return fmt.Errorf("node %q: openai_compat_instances[%d] %q: endpoint must not be empty", name, j, inst.Name)
}
}
if n.Runtime.Concurrency < 0 {
return fmt.Errorf("node %q: runtime concurrency must be non-negative", name)
}
}
return nil
}
func anyOllamaEnabled(a config.AdaptersConf) bool {
if a.Ollama.Enabled {
return true
}
for _, inst := range a.OllamaInstances {
if inst.Enabled {
return true
}
}
return false
}
func anyVllmEnabled(a config.AdaptersConf) bool {
if a.Vllm.Enabled {
return true
}
for _, inst := range a.VllmInstances {
if inst.Enabled {
return true
}
}
return false
}
func anyOpenAICompatEnabled(a config.AdaptersConf) bool {
if a.OpenAICompat.Enabled {
return true
}
for _, inst := range a.OpenAICompatInstances {
if inst.Enabled {
return true
}
}
return false
}

View file

@ -26,6 +26,13 @@ func NewManager(cfg config.EdgeConfig, svc *edgeservice.Service, logger *zap.Log
return &Manager{OpenAI: openaiServer, A2A: a2aServer} return &Manager{OpenAI: openaiServer, A2A: a2aServer}
} }
func (m *Manager) SetModelCatalog(catalog []config.ModelCatalogEntry) {
if m == nil || m.OpenAI == nil {
return
}
m.OpenAI.SetModelCatalog(catalog)
}
func (m *Manager) Start(ctx context.Context) error { func (m *Manager) Start(ctx context.Context) error {
if err := m.OpenAI.Start(ctx); err != nil { if err := m.OpenAI.Start(ctx); err != nil {
return err return err

View file

@ -200,9 +200,11 @@ func (s *Server) findProviderPoolEntry(model string) *config.ModelCatalogEntry {
if model == "" { if model == "" {
return nil return nil
} }
for i := range s.modelCatalog { modelCatalog := s.modelCatalogSnapshot()
if s.modelCatalog[i].ID == model { for i := range modelCatalog {
return &s.modelCatalog[i] if modelCatalog[i].ID == model {
entry := modelCatalog[i]
return &entry
} }
} }
return nil return nil

View file

@ -26,9 +26,10 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
return return
} }
var modelIDs []string var modelIDs []string
if len(s.modelCatalog) > 0 { modelCatalog := s.modelCatalogSnapshot()
if len(modelCatalog) > 0 {
// Provider pool catalog takes priority over legacy model_routes. // Provider pool catalog takes priority over legacy model_routes.
for _, entry := range s.modelCatalog { for _, entry := range modelCatalog {
if id := strings.TrimSpace(entry.ID); id != "" { if id := strings.TrimSpace(entry.ID); id != "" {
modelIDs = append(modelIDs, id) modelIDs = append(modelIDs, id)
} }

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"sync"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
@ -20,6 +21,7 @@ type runService interface {
} }
type Server struct { type Server struct {
mu sync.RWMutex
cfg config.EdgeOpenAIConf cfg config.EdgeOpenAIConf
modelCatalog []config.ModelCatalogEntry modelCatalog []config.ModelCatalogEntry
service runService service runService
@ -38,7 +40,32 @@ func NewServer(cfg config.EdgeOpenAIConf, svc runService, logger *zap.Logger) *S
// When set, /v1/models lists catalog IDs and requests matching catalog entries // When set, /v1/models lists catalog IDs and requests matching catalog entries
// are dispatched via the provider pool instead of the legacy model_routes path. // are dispatched via the provider pool instead of the legacy model_routes path.
func (s *Server) SetModelCatalog(catalog []config.ModelCatalogEntry) { func (s *Server) SetModelCatalog(catalog []config.ModelCatalogEntry) {
s.modelCatalog = catalog s.mu.Lock()
s.modelCatalog = cloneModelCatalog(catalog)
s.mu.Unlock()
}
func (s *Server) modelCatalogSnapshot() []config.ModelCatalogEntry {
s.mu.RLock()
defer s.mu.RUnlock()
return cloneModelCatalog(s.modelCatalog)
}
func cloneModelCatalog(catalog []config.ModelCatalogEntry) []config.ModelCatalogEntry {
if len(catalog) == 0 {
return nil
}
out := make([]config.ModelCatalogEntry, len(catalog))
for i, entry := range catalog {
out[i] = entry
if entry.Providers != nil {
out[i].Providers = make(map[string]string, len(entry.Providers))
for k, v := range entry.Providers {
out[i].Providers[k] = v
}
}
}
return out
} }
func (s *Server) Enabled() bool { func (s *Server) Enabled() bool {

View file

@ -637,11 +637,11 @@ func TestResolveProviderPoolCandidatesFiltersInvalidProviders(t *testing.T) {
{ {
ID: "qwen3.6:35b", ID: "qwen3.6:35b",
Providers: map[string]string{ Providers: map[string]string{
"prov-available": "served-qwen", "prov-available": "served-qwen",
"prov-unavailable": "served-qwen", "prov-unavailable": "served-qwen",
"prov-mismatch": "served-qwen", "prov-mismatch": "served-qwen",
"prov-no-adapter": "served-qwen", "prov-no-adapter": "served-qwen",
"prov-cap-zero": "served-qwen", "prov-cap-zero": "served-qwen",
"prov-cap-unknown": "served-qwen", "prov-cap-unknown": "served-qwen",
}, },
}, },
@ -761,7 +761,8 @@ func TestResolveProviderPoolCandidatesFiltersInvalidProviders(t *testing.T) {
ModelGroupKey: "qwen3.6:35b", ModelGroupKey: "qwen3.6:35b",
ProviderPool: true, ProviderPool: true,
} }
candidates, policy, err := svc.resolveProviderPoolCandidates(req) storeSnapshot, catalogSnapshot := svc.runtimeConfigSnapshot()
candidates, policy, err := svc.resolveProviderPoolCandidates(req, storeSnapshot, catalogSnapshot)
if err != nil { if err != nil {
t.Fatalf("resolveProviderPoolCandidates: %v", err) t.Fatalf("resolveProviderPoolCandidates: %v", err)
} }

View file

@ -10,8 +10,8 @@ import (
"google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/structpb"
edgenode "iop/apps/edge/internal/node" edgenode "iop/apps/edge/internal/node"
eventpkg "iop/packages/go/events"
"iop/packages/go/config" "iop/packages/go/config"
eventpkg "iop/packages/go/events"
iop "iop/proto/gen/iop" iop "iop/proto/gen/iop"
) )
@ -204,8 +204,9 @@ func (s *Service) submitRunQueued(ctx context.Context, req SubmitRunRequest) (Ru
// given request. Provider-pool requests are resolved via the model catalog; // given request. Provider-pool requests are resolved via the model catalog;
// legacy requests are filtered by adapter/target capability. // legacy requests are filtered by adapter/target capability.
func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]candidateNode, groupPolicy, error) { func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]candidateNode, groupPolicy, error) {
store, catalog := s.runtimeConfigSnapshot()
if req.ProviderPool { if req.ProviderPool {
return s.resolveProviderPoolCandidates(req) return s.resolveProviderPoolCandidates(req, store, catalog)
} }
if req.NodeRef != "" { if req.NodeRef != "" {
entry, err := s.ResolveNode(req.NodeRef) entry, err := s.ResolveNode(req.NodeRef)
@ -213,8 +214,8 @@ func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]candidateNode,
return nil, groupPolicy{}, err return nil, groupPolicy{}, err
} }
cap := defaultNodeCapacity cap := defaultNodeCapacity
if s.nodeStore != nil { if store != nil {
if rec, ok := s.nodeStore.FindByID(entry.NodeID); ok { if rec, ok := store.FindByID(entry.NodeID); ok {
res := resolveAdapterForNode(rec, req.Adapter, req.Target) res := resolveAdapterForNode(rec, req.Adapter, req.Target)
if !res.supported { if !res.supported {
msg := fmt.Sprintf("node %q does not support adapter %q target %q", entry.NodeID, req.Adapter, req.Target) msg := fmt.Sprintf("node %q does not support adapter %q target %q", entry.NodeID, req.Adapter, req.Target)
@ -226,7 +227,7 @@ func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]candidateNode,
cap = res.capacity cap = res.capacity
} }
} }
policy := groupPolicyFromRequestOrStore(req, s.nodeStore, []*edgenode.NodeEntry{entry}) policy := groupPolicyFromRequestOrStore(req, store, []*edgenode.NodeEntry{entry})
return []candidateNode{{entry: entry, capacity: cap}}, policy, nil return []candidateNode{{entry: entry, capacity: cap}}, policy, nil
} }
@ -238,8 +239,8 @@ func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]candidateNode,
var candidates []candidateNode var candidates []candidateNode
for _, entry := range all { for _, entry := range all {
cap := defaultNodeCapacity cap := defaultNodeCapacity
if s.nodeStore != nil { if store != nil {
rec, ok := s.nodeStore.FindByID(entry.NodeID) rec, ok := store.FindByID(entry.NodeID)
if ok { if ok {
res := resolveAdapterForNode(rec, req.Adapter, req.Target) res := resolveAdapterForNode(rec, req.Adapter, req.Target)
if !res.supported { if !res.supported {
@ -258,7 +259,7 @@ func (s *Service) resolveQueueCandidates(req SubmitRunRequest) ([]candidateNode,
for i, c := range candidates { for i, c := range candidates {
entries[i] = c.entry entries[i] = c.entry
} }
policy := groupPolicyFromRequestOrStore(req, s.nodeStore, entries) policy := groupPolicyFromRequestOrStore(req, store, entries)
return candidates, policy, nil return candidates, policy, nil
} }
@ -488,11 +489,11 @@ func isProviderAvailable(health string) bool {
// that matches req.ModelGroupKey, then assembles per-provider candidateNodes // that matches req.ModelGroupKey, then assembles per-provider candidateNodes
// carrying the concrete served model name for target rewrite after admission. // carrying the concrete served model name for target rewrite after admission.
// Filters: served-model membership, dispatch adapter presence, and available health. // Filters: served-model membership, dispatch adapter presence, and available health.
func (s *Service) resolveProviderPoolCandidates(req SubmitRunRequest) ([]candidateNode, groupPolicy, error) { func (s *Service) resolveProviderPoolCandidates(req SubmitRunRequest, store *edgenode.NodeStore, catalog []config.ModelCatalogEntry) ([]candidateNode, groupPolicy, error) {
var catalogEntry *config.ModelCatalogEntry var catalogEntry *config.ModelCatalogEntry
for i := range s.modelCatalog { for i := range catalog {
if s.modelCatalog[i].ID == req.ModelGroupKey { if catalog[i].ID == req.ModelGroupKey {
catalogEntry = &s.modelCatalog[i] catalogEntry = &catalog[i]
break break
} }
} }
@ -510,10 +511,10 @@ func (s *Service) resolveProviderPoolCandidates(req SubmitRunRequest) ([]candida
policySet := false policySet := false
for _, entry := range all { for _, entry := range all {
if s.nodeStore == nil { if store == nil {
continue continue
} }
rec, ok := s.nodeStore.FindByID(entry.NodeID) rec, ok := store.FindByID(entry.NodeID)
if !ok { if !ok {
continue continue
} }

View file

@ -1,6 +1,8 @@
package service package service
import ( import (
"sync"
edgeevents "iop/apps/edge/internal/events" edgeevents "iop/apps/edge/internal/events"
edgenode "iop/apps/edge/internal/node" edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config" "iop/packages/go/config"
@ -18,6 +20,7 @@ const (
// (node command transport), and control_command.go (Control Plane command // (node command transport), and control_command.go (Control Plane command
// execution). // execution).
type Service struct { type Service struct {
mu sync.RWMutex
registry *edgenode.Registry registry *edgenode.Registry
events *edgeevents.Bus events *edgeevents.Bus
nodeStore *edgenode.NodeStore nodeStore *edgenode.NodeStore
@ -36,7 +39,23 @@ func New(registry *edgenode.Registry, events *edgeevents.Bus) *Service {
} }
func (s *Service) SetNodeStore(store *edgenode.NodeStore) { func (s *Service) SetNodeStore(store *edgenode.NodeStore) {
s.mu.Lock()
s.nodeStore = store s.nodeStore = store
s.mu.Unlock()
if s.queue != nil {
s.queue.setStore(store)
}
}
// SetRuntimeConfig atomically replaces the runtime config snapshot used by
// dispatch and status readers. A reader observes either the old node
// store/catalog pair or the new pair, never a mix.
func (s *Service) SetRuntimeConfig(store *edgenode.NodeStore, catalog []config.ModelCatalogEntry) {
catalog = cloneModelCatalog(catalog)
s.mu.Lock()
s.nodeStore = store
s.modelCatalog = catalog
s.mu.Unlock()
if s.queue != nil { if s.queue != nil {
s.queue.setStore(store) s.queue.setStore(store)
} }
@ -45,7 +64,32 @@ func (s *Service) SetNodeStore(store *edgenode.NodeStore) {
// SetModelCatalog provides the top-level provider-pool model catalog to the // SetModelCatalog provides the top-level provider-pool model catalog to the
// service. Must be called before the first provider-pool SubmitRun. // service. Must be called before the first provider-pool SubmitRun.
func (s *Service) SetModelCatalog(catalog []config.ModelCatalogEntry) { func (s *Service) SetModelCatalog(catalog []config.ModelCatalogEntry) {
s.modelCatalog = catalog s.mu.Lock()
s.modelCatalog = cloneModelCatalog(catalog)
s.mu.Unlock()
}
func (s *Service) runtimeConfigSnapshot() (*edgenode.NodeStore, []config.ModelCatalogEntry) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.nodeStore, cloneModelCatalog(s.modelCatalog)
}
func cloneModelCatalog(catalog []config.ModelCatalogEntry) []config.ModelCatalogEntry {
if len(catalog) == 0 {
return nil
}
out := make([]config.ModelCatalogEntry, len(catalog))
for i, entry := range catalog {
out[i] = entry
if entry.Providers != nil {
out[i].Providers = make(map[string]string, len(entry.Providers))
for k, v := range entry.Providers {
out[i].Providers[k] = v
}
}
}
return out
} }
func (s *Service) ListNodes() []*edgenode.NodeEntry { func (s *Service) ListNodes() []*edgenode.NodeEntry {

View file

@ -5,6 +5,7 @@ import (
"errors" "errors"
"net" "net"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@ -119,3 +120,94 @@ func TestSubmitRunModelQueueUsesRoutePolicyBeforeProviderInstancePolicy(t *testi
t.Error("timeout waiting for run2 goroutine to exit") t.Error("timeout waiting for run2 goroutine to exit")
} }
} }
func TestRuntimeConfigSnapshotConcurrentReplace(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-refresh"})
svc := New(reg, nil)
newStore := func(providerID, servedModel string, capacity int) *edgenode.NodeStore {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{
ID: "node-refresh",
Runtime: config.RuntimeConf{Concurrency: capacity},
Providers: []config.NodeProviderConf{
{
ID: providerID,
Type: "vllm",
Category: config.CategoryAPI,
Adapter: "vllm-gpu",
Models: []string{servedModel},
Health: "available",
Capacity: capacity,
},
},
})
return store
}
newCatalog := func(providerID, servedModel string) []config.ModelCatalogEntry {
return []config.ModelCatalogEntry{
{
ID: "qwen3.6:35b",
Providers: map[string]string{
providerID: servedModel,
},
},
}
}
storeA := newStore("prov-a", "served-a", 2)
catalogA := newCatalog("prov-a", "served-a")
storeB := newStore("prov-b", "served-b", 4)
catalogB := newCatalog("prov-b", "served-b")
svc.SetRuntimeConfig(storeA, catalogA)
const readers = 8
const iterations = 200
start := make(chan struct{})
errCh := make(chan error, readers)
var wg sync.WaitGroup
for i := 0; i < readers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for j := 0; j < iterations; j++ {
store, catalog := svc.runtimeConfigSnapshot()
candidates, _, err := svc.resolveProviderPoolCandidates(SubmitRunRequest{
ModelGroupKey: "qwen3.6:35b",
ProviderPool: true,
}, store, catalog)
if err != nil {
errCh <- err
return
}
if len(candidates) != 1 {
errCh <- errors.New("expected exactly one provider candidate")
return
}
_ = svc.ListNodeSnapshots()
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
<-start
for i := 0; i < iterations; i++ {
svc.SetRuntimeConfig(storeA, catalogA)
svc.SetRuntimeConfig(storeB, catalogB)
}
}()
close(start)
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("runtime config snapshot reader observed inconsistent state: %v", err)
}
}
}

View file

@ -23,6 +23,7 @@ type NodeSnapshot struct {
// current registry contents. // current registry contents.
func (s *Service) ListNodeSnapshots() []NodeSnapshot { func (s *Service) ListNodeSnapshots() []NodeSnapshot {
entries := s.registry.All() entries := s.registry.All()
store, _ := s.runtimeConfigSnapshot()
out := make([]NodeSnapshot, 0, len(entries)) out := make([]NodeSnapshot, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
snap := NodeSnapshot{ snap := NodeSnapshot{
@ -33,8 +34,8 @@ func (s *Service) ListNodeSnapshots() []NodeSnapshot {
LifecycleState: entry.LifecycleState, LifecycleState: entry.LifecycleState,
} }
var rec *edgenode.NodeRecord var rec *edgenode.NodeRecord
if s.nodeStore != nil { if store != nil {
if r, ok := s.nodeStore.FindByID(entry.NodeID); ok { if r, ok := store.FindByID(entry.NodeID); ok {
rec = r rec = r
if payload, err := edgenode.BuildConfigPayload(r); err == nil { if payload, err := edgenode.BuildConfigPayload(r); err == nil {
snap.Config = payload snap.Config = payload

View file

@ -49,6 +49,7 @@ type Server struct {
tcp *toki.TcpServer tcp *toki.TcpServer
listen string listen string
registry *edgenode.Registry registry *edgenode.Registry
nodeStoreMu sync.RWMutex
nodeStore *edgenode.NodeStore nodeStore *edgenode.NodeStore
logger *zap.Logger logger *zap.Logger
handlerMu sync.RWMutex handlerMu sync.RWMutex
@ -93,6 +94,18 @@ func (s *Server) Start(ctx context.Context) error {
return nil return nil
} }
func (s *Server) SetNodeStore(store *edgenode.NodeStore) {
s.nodeStoreMu.Lock()
s.nodeStore = store
s.nodeStoreMu.Unlock()
}
func (s *Server) nodeStoreSnapshot() *edgenode.NodeStore {
s.nodeStoreMu.RLock()
defer s.nodeStoreMu.RUnlock()
return s.nodeStore
}
func (s *Server) Stop() error { func (s *Server) Stop() error {
s.stopping.Store(true) s.stopping.Store(true)
return s.tcp.Stop() return s.tcp.Stop()
@ -142,7 +155,11 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) {
toki.AddRequestListenerTyped[*iop.RegisterRequest, *iop.RegisterResponse]( toki.AddRequestListenerTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator, &client.Communicator,
func(req *iop.RegisterRequest) (*iop.RegisterResponse, error) { func(req *iop.RegisterRequest) (*iop.RegisterResponse, error) {
rec, ok := s.nodeStore.FindByToken(req.GetToken()) nodeStore := s.nodeStoreSnapshot()
if nodeStore == nil {
return &iop.RegisterResponse{Accepted: false, Reason: "edge node store unavailable"}, nil
}
rec, ok := 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( s.emitNodeEvent(events.NewEdgeNodeEvent(

View file

@ -36,6 +36,12 @@ control_plane:
wire_addr: "" wire_addr: ""
reconnect_interval_sec: 5 reconnect_interval_sec: 5
# refresh enables the Edge-local admin API used by `iop-edge config refresh`.
# Keep it loopback-only unless an operator-specific access control layer is added.
refresh:
enabled: false
listen: "127.0.0.1:19093"
a2a: a2a:
enabled: false enabled: false
listen: "0.0.0.0:8081" listen: "0.0.0.0:8081"

View file

@ -36,6 +36,14 @@ type ReconnectConf struct {
MaxAttempts int `mapstructure:"max_attempts" yaml:"max_attempts"` MaxAttempts int `mapstructure:"max_attempts" yaml:"max_attempts"`
} }
// EdgeRefreshConf configures the Edge-local refresh admin HTTP server.
// When Enabled is false (default) the admin server is not started and
// config refresh is not available without a restart.
type EdgeRefreshConf struct {
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
Listen string `mapstructure:"listen" yaml:"listen"`
}
type EdgeConfig struct { type EdgeConfig struct {
Edge EdgeInfo `mapstructure:"edge" yaml:"edge"` Edge EdgeInfo `mapstructure:"edge" yaml:"edge"`
Server EdgeServerConf `mapstructure:"server" yaml:"server"` Server EdgeServerConf `mapstructure:"server" yaml:"server"`
@ -47,6 +55,7 @@ type EdgeConfig struct {
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"` Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
Console EdgeConsoleConf `mapstructure:"console" yaml:"console"` Console EdgeConsoleConf `mapstructure:"console" yaml:"console"`
ControlPlane EdgeControlPlaneConf `mapstructure:"control_plane" yaml:"control_plane"` ControlPlane EdgeControlPlaneConf `mapstructure:"control_plane" yaml:"control_plane"`
Refresh EdgeRefreshConf `mapstructure:"refresh" yaml:"refresh,omitempty"`
Nodes []NodeDefinition `mapstructure:"nodes" yaml:"nodes"` Nodes []NodeDefinition `mapstructure:"nodes" yaml:"nodes"`
// Models is the top-level model catalog. Each entry defines a canonical // Models is the top-level model catalog. Each entry defines a canonical
// routing key (ID) and its provider-pool mapping. When set it takes // routing key (ID) and its provider-pool mapping. When set it takes
@ -784,4 +793,6 @@ func setEdgeDefaults(v *viper.Viper) {
v.SetDefault("control_plane.enabled", false) v.SetDefault("control_plane.enabled", false)
v.SetDefault("control_plane.wire_addr", "") v.SetDefault("control_plane.wire_addr", "")
v.SetDefault("control_plane.reconnect_interval_sec", 5) v.SetDefault("control_plane.reconnect_interval_sec", 5)
v.SetDefault("refresh.enabled", false)
v.SetDefault("refresh.listen", "127.0.0.1:19093")
} }

View file

@ -43,6 +43,43 @@ func TestLoadEdge_EdgeIdentityEmptyByDefault(t *testing.T) {
} }
} }
func TestEdgeRefreshDefaults(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")
if err := os.WriteFile(f, []byte("server:\n listen: \"0.0.0.0:9090\"\n"), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg, err := config.LoadEdge(f)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Refresh.Enabled {
t.Fatalf("expected refresh.enabled=false by default, got true")
}
if cfg.Refresh.Listen != "127.0.0.1:19093" {
t.Fatalf("expected refresh.listen=%q by default, got %q", "127.0.0.1:19093", cfg.Refresh.Listen)
}
}
func TestEdgeRefreshUnmarshal(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")
yaml := "server:\n listen: \"0.0.0.0:9090\"\nrefresh:\n enabled: true\n listen: \"127.0.0.1:0\"\n"
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg, err := config.LoadEdge(f)
if err != nil {
t.Fatalf("load: %v", err)
}
if !cfg.Refresh.Enabled {
t.Fatalf("expected refresh.enabled=true")
}
if cfg.Refresh.Listen != "127.0.0.1:0" {
t.Fatalf("expected refresh.listen=%q, got %q", "127.0.0.1:0", cfg.Refresh.Listen)
}
}
func TestLoadEdge_ConsoleTimeoutDefault(t *testing.T) { func TestLoadEdge_ConsoleTimeoutDefault(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml") f := filepath.Join(dir, "edge.yaml")

View file

@ -332,6 +332,9 @@ func TestEdgeSpecConfigTemplateUsesServiceWritableLogPath(t *testing.T) {
"control_plane:", "control_plane:",
"wire_addr:", "wire_addr:",
"reconnect_interval_sec:", "reconnect_interval_sec:",
"refresh:",
"enabled: false",
"listen: \"127.0.0.1:19093\"",
"metrics:", "metrics:",
"port: 19092", "port: 19092",
} }
@ -352,6 +355,9 @@ func TestEdgeBundleConfigTemplateKeepsEmptyLogPath(t *testing.T) {
"control_plane:", "control_plane:",
"wire_addr:", "wire_addr:",
"reconnect_interval_sec:", "reconnect_interval_sec:",
"refresh:",
"enabled: false",
"listen: \"127.0.0.1:19093\"",
"metrics:", "metrics:",
"port: 19092", "port: 19092",
} }

View file

@ -34,6 +34,10 @@ control_plane:
wire_addr: "" wire_addr: ""
reconnect_interval_sec: 5 reconnect_interval_sec: 5
refresh:
enabled: false
listen: "127.0.0.1:19093"
nodes: [] nodes: []
` `
@ -70,6 +74,10 @@ control_plane:
wire_addr: "" wire_addr: ""
reconnect_interval_sec: 5 reconnect_interval_sec: 5
refresh:
enabled: false
listen: "127.0.0.1:19093"
nodes: [] nodes: []
` `