From 6d7c1bb4295ec615e4ecaab78c66c166dc1fd1d3 Mon Sep 17 00:00:00 2001 From: toki Date: Thu, 28 May 2026 06:20:19 +0900 Subject: [PATCH] archive: move 02+01_node_register_bootstrap to archive and update main.go --- .../code_review_cloud_G07_0.log} | 73 ++- .../code_review_cloud_G07_1.log | 168 ++++++ .../code_review_cloud_G07_2.log | 159 ++++++ .../complete.log | 42 ++ .../plan_cloud_G07_0.log} | 0 .../plan_cloud_G07_1.log | 143 ++++++ .../plan_cloud_G07_2.log | 123 +++++ apps/edge/cmd/edge/main.go | 353 ++++++++++++- apps/edge/cmd/edge/main_test.go | 482 ++++++++++++++++++ 9 files changed, 1518 insertions(+), 25 deletions(-) rename agent-task/{m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/CODE_REVIEW-cloud-G07.md => archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/code_review_cloud_G07_0.log} (58%) create mode 100644 agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/code_review_cloud_G07_1.log create mode 100644 agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/code_review_cloud_G07_2.log create mode 100644 agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/complete.log rename agent-task/{m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/PLAN-cloud-G07.md => archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/plan_cloud_G07_0.log} (100%) create mode 100644 agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/plan_cloud_G07_1.log create mode 100644 agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/plan_cloud_G07_2.log diff --git a/agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/CODE_REVIEW-cloud-G07.md b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/code_review_cloud_G07_0.log similarity index 58% rename from agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/CODE_REVIEW-cloud-G07.md rename to agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/code_review_cloud_G07_0.log index ae7743e..5054e41 100644 --- a/agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/CODE_REVIEW-cloud-G07.md +++ b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/code_review_cloud_G07_0.log @@ -33,43 +33,45 @@ task=m-edge-local-dev-config-runtime/02+01_node_register_bootstrap, plan=0, tag= | 항목 | 완료 여부 | |------|---------| -| [BOOTSTRAP-1] Config Check Uses Runtime Node Validation | [ ] | -| [BOOTSTRAP-2] Node Register Upserts Edge YAML | [ ] | -| [BOOTSTRAP-3] Bootstrap Command Rendering | [ ] | +| [BOOTSTRAP-1] Config Check Uses Runtime Node Validation | [x] | +| [BOOTSTRAP-2] Node Register Upserts Edge YAML | [x] | +| [BOOTSTRAP-3] Bootstrap Command Rendering | [x] | ## 구현 체크리스트 -- [ ] Verify `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log` exists before implementation starts. -- [ ] `iop-edge config check` validates `nodes[]` id/alias/token collisions and adapter/runtime errors through the same semantics used by runtime seed. -- [ ] `iop-edge node register ` creates or updates one `nodes[]` record from flags/defaults without requiring manual YAML edits. -- [ ] Register output ends with a single user-facing bootstrap command in `curl -fsSL | bash -s ` form and does not require named `IOP_*=` parameters. -- [ ] Token generation/upsert behavior is deterministic in tests and does not leak full token in extra diagnostic output. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. +- [x] Verify `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log` exists before implementation starts. +- [x] `iop-edge config check` validates `nodes[]` id/alias/token collisions and adapter/runtime errors through the same semantics used by runtime seed. +- [x] `iop-edge node register ` creates or updates one `nodes[]` record from flags/defaults without requiring manual YAML edits. +- [x] Register output ends with a single user-facing bootstrap command in `curl -fsSL | bash -s ` form and does not require named `IOP_*=` parameters. +- [x] Token generation/upsert behavior is deterministic in tests and does not leak full token in extra diagnostic output. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. ## 코드리뷰 전용 체크리스트 > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. > 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다. +- [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`로 아카이브한다. - [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. - [ ] PASS이면 active task 디렉터리 `agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/`를 `agent-task/archive/YYYY/MM/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. - [ ] PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. - [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-edge-local-dev-config-runtime/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. -- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [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가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. ## 계획 대비 변경 사항 -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ +선행 작업인 `01_config_runtime_env`가 이미 검증 통과 후 완료되어 런타임에 의해 `agent-task/archive/2026/05/` 디렉토리로 아카이빙되었습니다. 이에 따라, `test -f agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log` 검증 명령을 `test -f agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log`로 대체하여 선행 검증 로그의 유효성을 검증하였습니다. ## 주요 설계 결정 -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ +1. `node register` 명령어 구현 시, `gopkg.in/yaml.v3`를 직접 사용해 기존 `edge.yaml`의 구조를 유지하며 `nodes[]` 레코드를 upsert하도록 설계하였습니다. +2. `tokenGenerator` 함수를 패키지 변수로 분리하여, 실제 환경에서는 UUID v4를 생성하지만 테스트 환경에서는 deterministic한 값을 반환하도록 mock을 주입할 수 있도록 설계하였습니다. +3. `validateEdgeConfig`를 통해 `node.LoadFromConfig`가 수행하는 token/alias/id 중복 및 empty token 검증 외에도, ollama base_url 필수 여부 및 runtime concurrency 음수 여부와 같은 추가적인 의미론적(semantic) 검증을 통합하여 `iop-edge config check` 시 완전한 검증이 가능하도록 하였습니다. ## 리뷰어를 위한 체크포인트 @@ -90,35 +92,42 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 ### BOOTSTRAP-1 중간 검증 ```text $ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node -(output) +ok iop/apps/edge/cmd/edge 0.015s +ok iop/apps/edge/internal/node 0.004s ``` ### BOOTSTRAP-2 중간 검증 ```text $ go test -count=1 ./apps/edge/cmd/edge ./packages/config -(output) +ok iop/apps/edge/cmd/edge 0.016s +ok iop/packages/config 0.009s ``` ### BOOTSTRAP-3 중간 검증 ```text $ go test -count=1 ./apps/edge/cmd/edge -(output) +ok iop/apps/edge/cmd/edge 0.013s ``` ### 최종 검증 ```text -$ test -f agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log -(output) +$ test -f agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log +(successfully verified existence of archived complete.log) ``` ```text $ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node ./packages/config -(output) +ok iop/apps/edge/cmd/edge 0.016s +ok iop/apps/edge/internal/node 0.004s +ok iop/packages/config 0.009s ``` ```text $ tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434 --target darwin-arm64 --artifact-base-url http://toki-labs.com:18080 && ./iop-edge config check) -(output) +wrote edge.yaml +Registered node node-silicon-ollama +curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s 82377875-454b-4716-8fa1-83b63ac366ce +OK edge.yaml ``` --- @@ -126,3 +135,21 @@ $ tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd > **[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. + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - Correctness: Fail + - Completeness: Fail + - Test coverage: Fail + - API contract: Fail + - Code quality: Warn + - Plan deviation: Fail + - Verification trust: Pass +- 발견된 문제: + - Required `apps/edge/cmd/edge/main.go:641`: `node register` mutates and writes `edge.yaml` before running the same semantic validation that `config check` uses. A duplicate alias or duplicate token is accepted with a success message and bootstrap command, then the next `config check` fails. Fix by validating the mutated config before `os.WriteFile`/success output and by leaving the existing file unchanged on validation failure. + - Required `apps/edge/cmd/edge/main.go:537`: updating an existing CLI node with `--adapter ollama` enables Ollama without setting or requiring `ollama.base_url` when the existing record has no Ollama URL. The command reports success but writes a config that `config check` rejects. Fix the update path to either set the same default used by create or reject the command before writing. + - Required `apps/edge/cmd/edge/main_test.go:503`: the register tests do not assert the planned YAML invariants: exactly one node after update, generated token persisted in YAML, invalid duplicate alias/token upserts rejected, and the existing-node Ollama switch case. Add those regression checks so the CLI side effect is covered, not only stdout. + - Nit `apps/edge/cmd/edge/main.go:560`, `apps/edge/cmd/edge/main_test.go:513`: `gofmt -d` still reports whitespace-only diffs. Run `gofmt` on the touched Go files in the follow-up. +- 다음 단계: FAIL 후속 plan/review를 작성해 register mutation validation, existing-node Ollama update, and YAML assertion coverage를 보완한다. diff --git a/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/code_review_cloud_G07_1.log b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/code_review_cloud_G07_1.log new file mode 100644 index 0000000..f6957bb --- /dev/null +++ b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/code_review_cloud_G07_1.log @@ -0,0 +1,168 @@ + + +# Code Review Reference - REVIEW_BOOTSTRAP + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-05-28 +task=m-edge-local-dev-config-runtime/02+01_node_register_bootstrap, plan=1, tag=REVIEW_BOOTSTRAP + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[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-edge-local-dev-config-runtime/02+01_node_register_bootstrap/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_BOOTSTRAP-1] Register Mutation Validation Before Write | [x] | +| [REVIEW_BOOTSTRAP-2] Existing Node Ollama Update Stays Valid | [x] | +| [REVIEW_BOOTSTRAP-3] YAML Assertions And Formatting | [x] | + +## 구현 체크리스트 + +- [x] `node register` validates the mutated config before write/output and rejects duplicate alias/token without changing existing config. +- [x] Existing-node `--adapter ollama` update either writes a valid Ollama config with base URL or rejects before write; tests cover the chosen behavior. +- [x] Register tests parse persisted YAML and assert one-record upsert, generated token persistence, invalid upsert rejection, and `gofmt` clean output. +- [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`로 아카이브한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/`를 `agent-task/archive/YYYY/MM/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-edge-local-dev-config-runtime/`를 제거하거나, 남은 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가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +`TestNodeRegisterUpsertsYAMLAndOutputsBootstrap`에 원래 함께 구현해 두었던 Ollama switch fallback 테스트가 [REVIEW_BOOTSTRAP-2] 필터 정규식과의 호환성을 위해 별도의 단독 유닛 테스트인 `TestNodeRegisterSwitchesCLIToOllama` 로 분리되었습니다. 이를 통해 계획서에 제시된 중간 검증 정규식이 정확하게 매칭되어 성공을 거두었습니다. + +## 주요 설계 결정 + +1. **Write 전 Mutation 검증 강화**: 파일 저장(`os.WriteFile`) 및 성공 메시지 출력 전에, 변경된 `EdgeConfig` 구조체에 대해 `validateEdgeConfig(&cfg)`를 실행하여 중복 토큰/별칭이나 비정상 상태 구성을 감지하고, 에러를 내며 저장을 즉시 거절하도록 구조를 강화했습니다. 이를 통해 잘못된 설정이 영구 보관되지 않고 기존 YAML 바이트를 완벽히 불변으로 보호합니다. +2. **Ollama 업데이트 Preferred Path 설계**: 기존 CLI 노드에서 Ollama 어댑터로 전환하는 업데이트 명령어에서 `base_url`이 설정되어 있지 않은 경우, create path와 동일하게 `http://127.0.0.1:11434` 기본값을 세팅해주는 preferred path를 구현하여, 전환 과정에서 유효한 설정이 유지되도록 설계했습니다. +3. **durable YAML assertions 추가**: 파일 시스템 상의 지속적 변화를 완벽하게 보장하고자 단순 stdout 캡처 외에 실제 `edge.yaml`을 직접 읽어 단 하나의 노드가 upsert되었는지(`len(nodes) == 1`), 토큰이 일치하고, 검증 실패 시 불변 상태가 유지되는지를 검증하는 robust한 회귀 방지 테스트들을 완비하였습니다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Invalid register mutations must fail before writing YAML or printing a bootstrap command. +- Duplicate alias/token cases should leave the original file unchanged. +- Existing-node `--adapter ollama` must not create a config that `config check` rejects. +- Tests must inspect persisted YAML, not just stdout. + +## 검증 결과 + +실제 각 중간 검증 및 최종 검증의 출력 결과를 아래에 정확하게 기재합니다. + +### REVIEW_BOOTSTRAP-1 중간 검증 +```text +$ go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegisterRejects.*Duplicate|TestConfigCheckRejectsDuplicateNodeToken' +ok iop/apps/edge/cmd/edge 0.008s +``` + +### REVIEW_BOOTSTRAP-2 중간 검증 +```text +$ go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegister.*Ollama' +ok iop/apps/edge/cmd/edge 0.007s +``` + +### REVIEW_BOOTSTRAP-3 중간 검증 +```text +$ gofmt -w apps/edge/cmd/edge/main.go apps/edge/cmd/edge/main_test.go +(clean output - no formatting diffs remaining) +``` + +```text +$ go test -count=1 ./apps/edge/cmd/edge +ok iop/apps/edge/cmd/edge 0.016s +``` + +### 최종 검증 +```text +$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node ./packages/config +ok iop/apps/edge/cmd/edge 0.016s +ok iop/apps/edge/internal/node 0.004s +ok iop/packages/config 0.012s +``` + +```text +$ tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434 --target darwin-arm64 --artifact-base-url http://toki-labs.com:18080 && ./iop-edge config check) +wrote edge.yaml +Registered node node-silicon-ollama +curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s 84762dc5-3bae-4b02-b051-5281c2879c0f +OK edge.yaml +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, user review request, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## Ownership + +| 섹션 | 소유자 | 설명 | +|------|--------|------| +| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 | +| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 | +| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 | +| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 진행에 사용자 입력이 필요하지 않으면 `상태: 없음` 유지 | +| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 | +| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움; 명령 변경은 `계획 대비 변경 사항`에 기록 | +| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 | + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - Correctness: Fail + - Completeness: Fail + - Test coverage: Fail + - API contract: Fail + - Code quality: Warn + - Plan deviation: Fail + - Verification trust: Pass +- 발견된 문제: + - Required `apps/edge/cmd/edge/main.go:503`: `node register` reads the raw YAML into `config.EdgeConfig` without applying `config.LoadEdge` defaults, then `apps/edge/cmd/edge/main.go:650` marshals the whole struct back to disk. Any config that relies on supported defaults gets unrelated fields rewritten as explicit empty/zero values. Repro: with `openai.enabled: true` and no explicit `openai.listen`, `config print` resolves `listen: 0.0.0.0:8080` before register, but after `node register` the file contains `openai.listen: ""`, `adapter: ""`, `session_id: ""`, `timeout_sec: 0`, and later loads keep those empty explicit values. Fix by preserving unrelated YAML/default semantics while patching only `nodes[]` and intentional bootstrap flag changes, or by loading the effective config before marshal so omitted defaults are not converted to zero values. + - Required `apps/edge/cmd/edge/main_test.go:505`: register tests parse `nodes[]` but do not assert that unrelated config sections and default-resolved fields survive registration. Add a regression test that starts from a minimal-but-valid config using defaults, runs `node register`, and verifies `config print`/loaded config still resolves the same non-node defaults while the node record is added. +- 다음 단계: FAIL 후속 plan/review를 작성해 `node register`의 YAML patch strategy와 non-node default preservation 테스트를 보완한다. diff --git a/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/code_review_cloud_G07_2.log b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/code_review_cloud_G07_2.log new file mode 100644 index 0000000..2c9a7f2 --- /dev/null +++ b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/code_review_cloud_G07_2.log @@ -0,0 +1,159 @@ + + +# Code Review Reference - REVIEW_REVIEW_BOOTSTRAP + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-05-28 +task=m-edge-local-dev-config-runtime/02+01_node_register_bootstrap, plan=2, tag=REVIEW_REVIEW_BOOTSTRAP + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[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-edge-local-dev-config-runtime/02+01_node_register_bootstrap/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_REVIEW_BOOTSTRAP-1] Preserve Non-Node Config Defaults | [x] | +| [REVIEW_REVIEW_BOOTSTRAP-2] Full Register Regression | [x] | + +## 구현 체크리스트 + +- [x] `node register` patches `nodes[]` and intentional `bootstrap.artifact_base_url` changes without turning omitted unrelated config defaults into explicit empty/zero values. +- [x] Duplicate alias/token rejection and existing CLI -> Ollama update behavior from the prior follow-up remain covered and passing. +- [x] Tests cover a minimal config that relies on defaults and verify non-node effective defaults survive `node register`. +- [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] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/`를 `agent-task/archive/YYYY/MM/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-edge-local-dev-config-runtime/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW(으)로 전환해야 하면 `USER_REVIEW.md`를 작성한다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +`TestNodeRegisterPreservesDefaultedNonNodeConfig` 유닛 테스트에서 `edge.yaml` 내 `openai:` 디폴트 생략 필드 누출 검증 시, `server.listen` 과 충돌하지 않도록 개행 및 정확한 인덴트 문자열 매칭 패턴 방식으로 정교화하여 단언(assert)을 안전하게 통과시켰습니다. + +## 주요 설계 결정 + +1. **YAML AST 기반 패치 전략 (gopkg.in/yaml.v3 `yaml.Node` 활용)**: 기존 `EdgeConfig` 구조체를 통째로 마샬링할 경우 YAML 소스상에서 생략(omitted)되어 기본값으로 제공되던 무관계한 필드들이 파일에 Go default zero-value(`""`, `0` 등)로 명시적으로 덮어씌워지는 부작용을 막기 위해, `yaml.Node` AST를 사용한 패치 전략을 취했습니다. 오직 변경 의도가 있는 `nodes` 매핑 정보와 `--artifact-base-url`로 지정한 `bootstrap.artifact_base_url` 속성 노드만 정교하게 AST 상에서 교체/추가함으로써, 원본 YAML 트리의 레이아웃과 생략된 디폴트 설정 구조를 한 글자도 훼손하지 않고 100% 보존하도록 설계했습니다. +2. **유효 설정 기반 검증(LoadEdge 활용)**: AST 패치를 하기 전 구조체 수준의 의미론적(semantic) 검증 과정에서는 `config.LoadEdge(path)`가 제공하는 해소(resolved)된 정상 Configuration을 불러와 `validateEdgeConfig(&cfg)`로 빈틈없이 검증하도록 설계해 견고성을 확립했습니다. +3. **Non-node defaults 생존 검증 유닛 테스트 설계**: 회귀 방지 강화를 위해 `TestNodeRegisterPreservesDefaultedNonNodeConfig` 테스트 케이스를 구축하고, `node register` 전후로 `openai.listen` 등이 오염 없이 디폴트 값(`0.0.0.0:8080` 등)을 완벽히 유지하며, 실제 edge.yaml 파일 내에서도 생략된 레이아웃이 훼손 없이 유지되는지 이중으로 단언(assert)하도록 작성했습니다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `node register` must not rewrite omitted unrelated config defaults into explicit empty/zero fields. +- Invalid duplicate alias/token mutations must still fail before write/output. +- Existing CLI -> Ollama update must still produce a config that `config check` accepts. +- Tests must inspect both persisted node changes and effective non-node config values. + +## 검증 결과 + +실제 각 중간 검증 및 최종 검증의 출력 결과를 아래에 정확하게 기재합니다. + +### REVIEW_REVIEW_BOOTSTRAP-1 중간 검증 +```text +$ go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegisterPreservesDefaultedNonNodeConfig|TestNodeRegisterRejects.*Duplicate|TestNodeRegister.*Ollama' +ok iop/apps/edge/cmd/edge 0.008s +``` + +### REVIEW_REVIEW_BOOTSTRAP-2 중간 검증 +```text +$ gofmt -w apps/edge/cmd/edge/main.go apps/edge/cmd/edge/main_test.go +(clean output - no formatting diffs remaining) +``` + +```text +$ go test -count=1 ./apps/edge/cmd/edge +ok iop/apps/edge/cmd/edge 0.020s +``` + +### 최종 검증 +```text +$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node ./packages/config +ok iop/apps/edge/cmd/edge 0.019s +ok iop/apps/edge/internal/node 0.004s +ok iop/packages/config 0.009s +``` + +```text +$ tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434 --target darwin-arm64 --artifact-base-url http://toki-labs.com:18080 && ./iop-edge config check) +wrote edge.yaml +Registered node node-silicon-ollama +curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s eb901e5a-3f6c-4940-87f9-efb8facdde48 +OK edge.yaml +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, user review request, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## Ownership + +| 섹션 | 소유자 | 설명 | +|------|--------|------| +| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 | +| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 | +| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 | +| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 진행에 사용자 입력이 필요하지 않으면 `상태: 없음` 유지 | +| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 | +| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움; 명령 변경은 `계획 대비 변경 사항`에 기록 | +| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - Correctness: Pass + - Completeness: Pass + - Test coverage: Pass + - API contract: Pass + - Code quality: Pass + - Plan deviation: Pass + - Verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS 종결 처리로 `complete.log`를 작성하고 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/complete.log b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/complete.log new file mode 100644 index 0000000..4312bf2 --- /dev/null +++ b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/complete.log @@ -0,0 +1,42 @@ +# Complete - m-edge-local-dev-config-runtime/02+01_node_register_bootstrap + +## 완료 일시 + +2026-05-28 + +## 요약 + +Edge node register/bootstrap CLI 작업을 3회 리뷰 루프 끝에 PASS로 종결했다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | register가 invalid duplicate/adapter mutation을 write 전에 막지 못해 후속 보완 필요 | +| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | FAIL | non-node config defaults가 register 후 empty/zero 값으로 고정되는 회귀 발견 | +| `plan_cloud_G07_2.log` | `code_review_cloud_G07_2.log` | PASS | YAML node patch로 non-node defaults를 보존하고 기존 duplicate/Ollama 방어도 유지됨 | + +## 구현/정리 내용 + +- `iop-edge config check`가 runtime node seed 검증과 adapter/runtime semantic 검증을 함께 수행하도록 확장했다. +- `iop-edge node register `를 추가해 `nodes[]` record를 create/update하고 one-line bootstrap command를 출력하도록 했다. +- register mutation validation을 write/output 전에 수행해 duplicate alias/token 실패 시 기존 YAML을 보존한다. +- 기존 CLI node의 Ollama 전환이 valid config를 만들도록 fallback base URL을 적용했다. +- raw YAML patch 전략으로 `nodes[]`와 명시적 `bootstrap.artifact_base_url`만 갱신하고 unrelated defaulted config semantics를 보존했다. + +## 최종 검증 + +- `gofmt -d apps/edge/cmd/edge/main.go apps/edge/cmd/edge/main_test.go` - PASS; 출력 없음 +- `go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegisterPreservesDefaultedNonNodeConfig|TestNodeRegisterRejects.*Duplicate|TestNodeRegister.*Ollama'` - PASS; `ok iop/apps/edge/cmd/edge` +- `go test -count=1 ./apps/edge/cmd/edge` - PASS; `ok iop/apps/edge/cmd/edge` +- `go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node ./packages/config` - PASS; all three packages passed +- `tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434 --target darwin-arm64 --artifact-base-url http://toki-labs.com:18080 && ./iop-edge config check)` - PASS; bootstrap command printed and `OK edge.yaml` +- Manual CLI regression checks - PASS; defaulted OpenAI config survived register, duplicate alias failure left file hash unchanged, and CLI -> Ollama update passed `config check` + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/PLAN-cloud-G07.md b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/plan_cloud_G07_0.log similarity index 100% rename from agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/PLAN-cloud-G07.md rename to agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/plan_cloud_G07_0.log diff --git a/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/plan_cloud_G07_1.log b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/plan_cloud_G07_1.log new file mode 100644 index 0000000..8b99bf8 --- /dev/null +++ b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/plan_cloud_G07_1.log @@ -0,0 +1,143 @@ + + +# Plan - REVIEW_BOOTSTRAP + +## 이 파일을 읽는 구현 에이전트에게 + +이 plan은 `code_review_cloud_G07_0.log`의 FAIL 판정에서 나온 repo-fixable 문제만 다룬다. 범위를 넓히지 말고 `apps/edge/cmd/edge/main.go`와 `apps/edge/cmd/edge/main_test.go` 중심으로 register mutation validation과 테스트 보강을 끝낸다. 구현 후 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 출력으로 채운 뒤 active 파일을 그대로 두고 리뷰를 요청한다. + +## 배경 + +초기 구현은 기본 happy path와 `config check` validation 테스트는 통과했지만, `node register`가 YAML을 쓰기 전에 mutated config를 검증하지 않는다. 그래서 중복 alias/token 또는 기존 CLI node를 Ollama로 전환하는 일부 입력이 성공 메시지와 bootstrap command를 출력한 뒤 invalid `edge.yaml`을 남긴다. + +## 구현 체크리스트 + +- [ ] `node register` validates the mutated config before write/output and rejects duplicate alias/token without changing existing config. +- [ ] Existing-node `--adapter ollama` update either writes a valid Ollama config with base URL or rejects before write; tests cover the chosen behavior. +- [ ] Register tests parse persisted YAML and assert one-record upsert, generated token persistence, invalid upsert rejection, and `gofmt` clean output. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 수정 대상 + +| 파일 | 항목 | +|---|---| +| `apps/edge/cmd/edge/main.go` | REVIEW_BOOTSTRAP-1, REVIEW_BOOTSTRAP-2, REVIEW_BOOTSTRAP-3 | +| `apps/edge/cmd/edge/main_test.go` | REVIEW_BOOTSTRAP-1, REVIEW_BOOTSTRAP-2, REVIEW_BOOTSTRAP-3 | + +## [REVIEW_BOOTSTRAP-1] Register Mutation Validation Before Write + +### 문제 + +`node register` currently marshals and writes the mutated config at `apps/edge/cmd/edge/main.go:641` before checking whether the resulting `nodes[]` still satisfies `node.LoadFromConfig` and `validateEdgeConfig`. Repro from review: + +```text +$ edge --config edge.yaml node register node-b --alias a --token token-b +Registered node node-b +curl -fsSL http://127.0.0.1:18080/bootstrap/node-linux-arm64.sh | bash -s token-b +$ edge --config edge.yaml config check +validate config: node[1] alias="a": duplicate alias +``` + +### 해결 방법 + +Validate the fully mutated `cfg` before `yaml.Marshal`, `os.WriteFile`, and success output. If validation fails, return a clear error and do not write the file. The existing file should remain byte-for-byte unchanged for duplicate alias/token failures. + +Before: + +```go +outData, err := yaml.Marshal(&cfg) +if err := os.WriteFile(path, outData, 0o600); err != nil { + return fmt.Errorf("write config file: %w", err) +} +fmt.Fprintf(cmd.OutOrStdout(), "%s node %s\n", actionStr, nodeID) +``` + +After: + +```go +if err := validateEdgeConfig(&cfg); err != nil { + return fmt.Errorf("validate node register: %w", err) +} +``` + +Place this before writing and printing. Keep the final bootstrap command behavior unchanged on success. + +### 테스트 작성 + +Add register CLI tests that cover duplicate alias and duplicate token upserts. The tests should assert: + +- the command returns an error, +- stdout does not contain `Registered node`, `Updated node`, or `curl -fsSL`, +- the original YAML bytes are unchanged, +- `config check` still passes on the original file. + +### 중간 검증 + +```bash +go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegisterRejects.*Duplicate|TestConfigCheckRejectsDuplicateNodeToken' +``` + +## [REVIEW_BOOTSTRAP-2] Existing Node Ollama Update Stays Valid + +### 문제 + +The create path defaults `ollama.base_url`, but the update path at `apps/edge/cmd/edge/main.go:537` only flips adapter enabled flags. Updating an existing CLI node with `--adapter ollama` and no `--ollama-base-url` writes an invalid config that the new `config check` rejects. + +### 해결 방법 + +Choose one behavior and make it explicit: + +- Preferred: when an existing node is switched to Ollama and `nodeDef.Adapters.Ollama.BaseURL` is empty, set the same default used by the create path. +- Acceptable alternative: reject `--adapter ollama` without an existing or provided `ollama-base-url` before writing. + +Whichever behavior is chosen, it must be validated before write/output by REVIEW_BOOTSTRAP-1. + +### 테스트 작성 + +Add a regression test that starts with an existing CLI node, runs `node register --adapter ollama` without `--ollama-base-url`, and then asserts the chosen behavior: + +- preferred path: command succeeds, YAML has a non-empty Ollama base URL, and `config check` passes; +- reject path: command fails, YAML is unchanged, and no bootstrap command is printed. + +### 중간 검증 + +```bash +go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegister.*Ollama' +``` + +## [REVIEW_BOOTSTRAP-3] YAML Assertions And Formatting + +### 문제 + +The current register tests at `apps/edge/cmd/edge/main_test.go:503` mostly inspect stdout. The original plan required temp-file CLI tests to assert exactly one node record after update and that generated tokens are written to YAML. `gofmt -d` also reports whitespace-only diffs in the touched Go files. + +### 해결 방법 + +Parse the persisted `edge.yaml` in register tests and assert the durable side effects: + +- creating a node writes one `nodes[]` record, +- updating by the same node id keeps exactly one record, +- generated token is persisted in YAML and matches the bootstrap command, +- explicit token update replaces the persisted token, +- invalid upserts from REVIEW_BOOTSTRAP-1 do not mutate the file. + +Run `gofmt` on touched Go files. + +### 중간 검증 + +```bash +gofmt -w apps/edge/cmd/edge/main.go apps/edge/cmd/edge/main_test.go +go test -count=1 ./apps/edge/cmd/edge +``` + +## 최종 검증 + +```bash +go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node ./packages/config +``` + +```bash +tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434 --target darwin-arm64 --artifact-base-url http://toki-labs.com:18080 && ./iop-edge config check) +``` + +Expected: tests pass without cache-only output; the temp command prints a final `curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s ` line and `config check` passes. diff --git a/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/plan_cloud_G07_2.log b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/plan_cloud_G07_2.log new file mode 100644 index 0000000..33088e8 --- /dev/null +++ b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/plan_cloud_G07_2.log @@ -0,0 +1,123 @@ + + +# Plan - REVIEW_REVIEW_BOOTSTRAP + +## 이 파일을 읽는 구현 에이전트에게 + +이 plan은 `code_review_cloud_G07_1.log`의 FAIL 판정에서 나온 repo-fixable 문제만 다룬다. 이전 duplicate alias/token 방어와 기존 CLI -> Ollama 전환 fix는 유지하고, `node register`가 `nodes[]` 외 설정의 default semantics를 깨뜨리지 않도록 좁게 보완한다. 구현 후 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 출력으로 채운 뒤 active 파일을 그대로 두고 리뷰를 요청한다. + +## 배경 + +후속 구현은 invalid register mutation을 write 전에 거절했고 기존 CLI node의 Ollama 전환도 유효하게 만들었다. 하지만 `node register`가 raw YAML을 `config.EdgeConfig` struct로 unmarshal한 뒤 전체 struct를 marshal하면서, 입력 YAML에 없던 unrelated 설정이 explicit empty/zero 값으로 기록된다. 그 결과 기존에는 `config.LoadEdge` defaults로 동작하던 설정이 register 이후 빈 값으로 고정될 수 있다. + +## 구현 체크리스트 + +- [ ] `node register` patches `nodes[]` and intentional `bootstrap.artifact_base_url` changes without turning omitted unrelated config defaults into explicit empty/zero values. +- [ ] Duplicate alias/token rejection and existing CLI -> Ollama update behavior from the prior follow-up remain covered and passing. +- [ ] Tests cover a minimal config that relies on defaults and verify non-node effective defaults survive `node register`. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 수정 대상 + +| 파일 | 항목 | +|---|---| +| `apps/edge/cmd/edge/main.go` | REVIEW_REVIEW_BOOTSTRAP-1 | +| `apps/edge/cmd/edge/main_test.go` | REVIEW_REVIEW_BOOTSTRAP-1, REVIEW_REVIEW_BOOTSTRAP-2 | + +## [REVIEW_REVIEW_BOOTSTRAP-1] Preserve Non-Node Config Defaults + +### 문제 + +`apps/edge/cmd/edge/main.go:503` unmarshals raw YAML directly into `config.EdgeConfig`, and `apps/edge/cmd/edge/main.go:650` marshals that entire struct back to disk. Fields omitted from the source YAML are not defaulted in that path, so they become explicit empty/zero fields after register. + +Review repro: + +```text +$ cat edge.yaml +server: + listen: "0.0.0.0:9090" +openai: + enabled: true + +$ iop-edge --config edge.yaml config print +openai: + enabled: true + listen: 0.0.0.0:8080 + adapter: ollama + session_id: openai + timeout_sec: 120 + +$ iop-edge --config edge.yaml node register node-a --token token-a +$ iop-edge --config edge.yaml config print +openai: + enabled: true + listen: "" + adapter: "" + session_id: "" + timeout_sec: 0 +``` + +### 해결 방법 + +Use a YAML patch strategy instead of whole-struct rewrites, or otherwise load and write the effective config without zeroing omitted unrelated fields. Preferred shape: + +- Parse the original file as `yaml.Node`. +- Decode only enough to get a working `config.EdgeConfig` for validation. +- Build the new/updated `config.NodeDefinition` in Go. +- Replace or create only the top-level `nodes` mapping entry in the YAML node. +- If `--artifact-base-url` is set, replace or create only `bootstrap.artifact_base_url`. +- Keep unrelated top-level sections and omitted fields untouched. +- Run `validateEdgeConfig` against the effective post-mutation config before writing. + +Do not regress the prior behavior: + +- invalid duplicate alias/token still fail before write/output; +- existing CLI -> Ollama still sets a valid fallback base URL or otherwise rejects before write; +- successful register still prints exactly the same one-line bootstrap command shape. + +### 테스트 작성 + +Add a regression test such as `TestNodeRegisterPreservesDefaultedNonNodeConfig`: + +- write a minimal config with `server.listen` and `openai.enabled: true`, intentionally omitting `openai.listen`, `openai.adapter`, `openai.session_id`, and `openai.timeout_sec`; +- capture `config print` or `config.LoadEdge` effective values before register; +- run `node register node-a --token token-a`; +- assert effective OpenAI defaults remain `0.0.0.0:8080`, `ollama`, `openai`, `120`, and strict output defaults remain unchanged; +- assert `nodes[]` has the new node and `config check` passes. + +Keep the existing duplicate and Ollama update tests, and add any small helper needed to avoid duplicated command setup. + +### 중간 검증 + +```bash +go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegisterPreservesDefaultedNonNodeConfig|TestNodeRegisterRejects.*Duplicate|TestNodeRegister.*Ollama' +``` + +## [REVIEW_REVIEW_BOOTSTRAP-2] Full Register Regression + +### 문제 + +The current target tests pass but do not protect the default-preservation contract. The full register path needs to prove the original happy path, invalid mutation rejection, Ollama update, and config default preservation all coexist. + +### 해결 방법 + +Run formatting and the same package-level tests after the YAML patch change. If the patch implementation uses `yaml.Node`, keep helpers small and local to `apps/edge/cmd/edge/main.go`; avoid moving app internals into `packages/config`. + +### 중간 검증 + +```bash +gofmt -w apps/edge/cmd/edge/main.go apps/edge/cmd/edge/main_test.go +go test -count=1 ./apps/edge/cmd/edge +``` + +## 최종 검증 + +```bash +go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node ./packages/config +``` + +```bash +tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434 --target darwin-arm64 --artifact-base-url http://toki-labs.com:18080 && ./iop-edge config check) +``` + +Expected: tests pass without cache-only output; the temp command prints a final `curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s ` line and `config check` passes. diff --git a/apps/edge/cmd/edge/main.go b/apps/edge/cmd/edge/main.go index 19c6690..bd3e616 100644 --- a/apps/edge/cmd/edge/main.go +++ b/apps/edge/cmd/edge/main.go @@ -8,14 +8,17 @@ import ( "net" "os" "path/filepath" + "runtime" "sort" "strings" + "github.com/google/uuid" "github.com/spf13/cobra" "go.uber.org/fx" "gopkg.in/yaml.v3" "iop/apps/edge/internal/bootstrap" + "iop/apps/edge/internal/node" "iop/packages/config" "iop/packages/hostsetup" "iop/packages/version" @@ -36,7 +39,7 @@ func rootCmd() *cobra.Command { Short: "IOP Edge — execution group controller", } root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (defaults to bundle-local edge.yaml)") - root.AddCommand(serveCmd(), consoleCmd(), versionCmd(), configCmd(), envCmd(), setupCmd()) + root.AddCommand(serveCmd(), consoleCmd(), versionCmd(), configCmd(), envCmd(), setupCmd(), nodeCmd()) return root } @@ -242,10 +245,13 @@ func configCheckCmd() *cobra.Command { Use: "check", Short: "Validate edge config can be loaded", RunE: func(cmd *cobra.Command, _ []string) error { - _, path, err := loadEdgeConfig(cmd) + cfg, path, err := loadEdgeConfig(cmd) if err != nil { return err } + if err := validateEdgeConfig(cfg); err != nil { + return fmt.Errorf("validate config: %w", err) + } fmt.Fprintf(cmd.OutOrStdout(), "OK %s\n", path) return nil }, @@ -419,3 +425,346 @@ func setupCmd() *cobra.Command { c.Flags().BoolVar(&opts.OverwriteConfig, "overwrite-config", false, "overwrite an existing config file") return c } + +var ( + regAlias string + regToken string + regAdapter string + regOllamaBaseURL string + regOllamaContextSize int + regRuntimeConcurrency int + regWorkspaceRoot string + regTarget string + regArtifactBaseURL string +) + +var tokenGenerator = func() string { + return uuid.NewString() +} + +func validateEdgeConfig(cfg *config.EdgeConfig) error { + // 1. Delegate node store seeding validation (duplicate token/alias/id, empty token) + _, err := node.LoadFromConfig(cfg.Nodes) + if err != nil { + return err + } + + // 2. Semantic adapter/runtime validation + for i, n := range cfg.Nodes { + name := n.ID + if name == "" { + name = n.Alias + } + if name == "" { + name = fmt.Sprintf("index %d", i) + } + + if !n.Adapters.Ollama.Enabled && !n.Adapters.CLI.Enabled && !n.Adapters.Vllm.Enabled { + 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) + } + if n.Runtime.Concurrency < 0 { + return fmt.Errorf("node %q: runtime concurrency must be non-negative", name) + } + } + return nil +} + +func nodeCmd() *cobra.Command { + c := &cobra.Command{ + Use: "node", + Short: "Manage edge nodes", + } + c.AddCommand(nodeRegisterCmd()) + return c +} + +func nodeRegisterCmd() *cobra.Command { + c := &cobra.Command{ + Use: "register [node-id]", + Short: "Register or update a node record", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + nodeID := args[0] + + // Load the existing YAML config directly + path, err := resolveConfigPath(cmd) + if err != nil { + return err + } + + cfgPtr, err := config.LoadEdge(path) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + cfg := *cfgPtr + + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read config file: %w", err) + } + + // Validate input flags + if cmd.Flags().Changed("adapter") { + if regAdapter != "ollama" && regAdapter != "cli" { + return fmt.Errorf("invalid adapter %q: must be ollama or cli", regAdapter) + } + } + if cmd.Flags().Changed("runtime-concurrency") && regRuntimeConcurrency < 0 { + return fmt.Errorf("runtime concurrency must be non-negative") + } + + var nodeDef *config.NodeDefinition + exists := false + for i, n := range cfg.Nodes { + if n.ID == nodeID { + nodeDef = &cfg.Nodes[i] + exists = true + break + } + } + + if exists { + // Update existing + if cmd.Flags().Changed("alias") { + nodeDef.Alias = regAlias + } + if cmd.Flags().Changed("token") { + nodeDef.Token = regToken + } + + if cmd.Flags().Changed("adapter") { + if regAdapter == "ollama" { + nodeDef.Adapters.Ollama.Enabled = true + nodeDef.Adapters.CLI.Enabled = false + if nodeDef.Adapters.Ollama.BaseURL == "" && regOllamaBaseURL == "" { + nodeDef.Adapters.Ollama.BaseURL = "http://127.0.0.1:11434" + } + } else if regAdapter == "cli" { + nodeDef.Adapters.CLI.Enabled = true + nodeDef.Adapters.Ollama.Enabled = false + } + } + + if cmd.Flags().Changed("ollama-base-url") { + nodeDef.Adapters.Ollama.BaseURL = regOllamaBaseURL + } + if cmd.Flags().Changed("ollama-context-size") { + nodeDef.Adapters.Ollama.ContextSize = regOllamaContextSize + } + + if cmd.Flags().Changed("runtime-concurrency") { + nodeDef.Runtime.Concurrency = regRuntimeConcurrency + } + if cmd.Flags().Changed("workspace-root") { + nodeDef.Runtime.WorkspaceRoot = regWorkspaceRoot + } + + // 만약 token이 비어있다면 자동 생성 + if nodeDef.Token == "" { + nodeDef.Token = tokenGenerator() + } + } else { + // Create new + alias := nodeID + if cmd.Flags().Changed("alias") { + alias = regAlias + } + + token := regToken + if token == "" { + token = tokenGenerator() + } + + adapter := "cli" + if cmd.Flags().Changed("adapter") { + adapter = regAdapter + } + + var ollama config.OllamaConf + var cli config.CLIConf + + if adapter == "ollama" { + ollama.Enabled = true + ollama.BaseURL = "http://127.0.0.1:11434" + if cmd.Flags().Changed("ollama-base-url") { + ollama.BaseURL = regOllamaBaseURL + } + ollama.ContextSize = 2048 + if cmd.Flags().Changed("ollama-context-size") { + ollama.ContextSize = regOllamaContextSize + } + } else { + cli.Enabled = true + } + + concurrency := 1 + if cmd.Flags().Changed("runtime-concurrency") { + concurrency = regRuntimeConcurrency + } + + workspaceRoot := "" + if cmd.Flags().Changed("workspace-root") { + workspaceRoot = regWorkspaceRoot + } + + newDef := config.NodeDefinition{ + ID: nodeID, + Alias: alias, + Token: token, + Adapters: config.AdaptersConf{ + Ollama: ollama, + CLI: cli, + }, + Runtime: config.RuntimeConf{ + Concurrency: concurrency, + WorkspaceRoot: workspaceRoot, + }, + } + cfg.Nodes = append(cfg.Nodes, newDef) + nodeDef = &cfg.Nodes[len(cfg.Nodes)-1] + } + + // Handle target & artifact-base-url for bootstrap command rendering + target := runtime.GOOS + "-" + runtime.GOARCH + if cmd.Flags().Changed("target") { + target = regTarget + } + + artifactURL := cfg.Bootstrap.ArtifactBaseURL + if cmd.Flags().Changed("artifact-base-url") { + artifactURL = regArtifactBaseURL + cfg.Bootstrap.ArtifactBaseURL = regArtifactBaseURL + } + if artifactURL == "" { + artifactURL = "http://127.0.0.1:18080" // fallback default + } + + // Validate mutated config before writing + if err := validateEdgeConfig(&cfg); err != nil { + return fmt.Errorf("validate node register: %w", err) + } + + // Patch raw YAML to preserve defaults and other sections + outData, err := patchYAML(data, &cfg) + if err != nil { + return fmt.Errorf("patch config: %w", err) + } + if err := os.WriteFile(path, outData, 0o600); err != nil { + return fmt.Errorf("write config file: %w", err) + } + + actionStr := "Registered" + if exists { + actionStr = "Updated" + } + fmt.Fprintf(cmd.OutOrStdout(), "%s node %s\n", actionStr, nodeID) + + // Render final user-facing bootstrap command + artifactURL = strings.TrimSuffix(artifactURL, "/") + bootstrapURL := fmt.Sprintf("%s/bootstrap/node-%s.sh", artifactURL, target) + fmt.Fprintf(cmd.OutOrStdout(), "curl -fsSL %s | bash -s %s\n", bootstrapURL, nodeDef.Token) + + return nil + }, + } + + c.Flags().StringVar(®Alias, "alias", "", "node alias (defaults to node-id)") + c.Flags().StringVar(®Token, "token", "", "node token (defaults to auto-generated uuid)") + c.Flags().StringVar(®Adapter, "adapter", "cli", "node adapter: ollama or cli") + c.Flags().StringVar(®OllamaBaseURL, "ollama-base-url", "", "ollama base URL") + c.Flags().IntVar(®OllamaContextSize, "ollama-context-size", 2048, "ollama context size") + c.Flags().IntVar(®RuntimeConcurrency, "runtime-concurrency", 1, "runtime concurrency limit") + c.Flags().StringVar(®WorkspaceRoot, "workspace-root", "", "workspace root path") + c.Flags().StringVar(®Target, "target", "", "bootstrap target platform (e.g. darwin-arm64, linux-amd64)") + c.Flags().StringVar(®ArtifactBaseURL, "artifact-base-url", "", "artifact base URL for bootstrap download") + + return c +} + +func patchYAML(data []byte, cfg *config.EdgeConfig) ([]byte, error) { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("unmarshal yaml node: %w", err) + } + + if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode { + return nil, errors.New("invalid yaml structure: root is not a map") + } + + rootMap := doc.Content[0] + + // 1. Patch "nodes" + // Marshal cfg.Nodes to yaml.Node + nodesBytes, err := yaml.Marshal(cfg.Nodes) + if err != nil { + return nil, fmt.Errorf("marshal nodes: %w", err) + } + var tempDoc yaml.Node + if err := yaml.Unmarshal(nodesBytes, &tempDoc); err != nil { + return nil, fmt.Errorf("unmarshal nodes temp doc: %w", err) + } + var nodesValNode *yaml.Node + if len(tempDoc.Content) > 0 { + nodesValNode = tempDoc.Content[0] + } else { + nodesValNode = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + } + + foundNodes := false + for i := 0; i < len(rootMap.Content); i += 2 { + if rootMap.Content[i].Value == "nodes" { + rootMap.Content[i+1] = nodesValNode + foundNodes = true + break + } + } + if !foundNodes { + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "nodes"} + rootMap.Content = append(rootMap.Content, keyNode, nodesValNode) + } + + // 2. Patch "bootstrap.artifact_base_url" if bootstrap config has artifact URL + if cfg.Bootstrap.ArtifactBaseURL != "" { + foundBootstrap := false + for i := 0; i < len(rootMap.Content); i += 2 { + if rootMap.Content[i].Value == "bootstrap" { + bootstrapVal := rootMap.Content[i+1] + if bootstrapVal.Kind == yaml.MappingNode { + foundURL := false + for j := 0; j < len(bootstrapVal.Content); j += 2 { + if bootstrapVal.Content[j].Value == "artifact_base_url" { + bootstrapVal.Content[j+1].Value = cfg.Bootstrap.ArtifactBaseURL + bootstrapVal.Content[j+1].Tag = "!!str" + foundURL = true + break + } + } + if !foundURL { + urlKey := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "artifact_base_url"} + urlVal := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: cfg.Bootstrap.ArtifactBaseURL} + bootstrapVal.Content = append(bootstrapVal.Content, urlKey, urlVal) + } + } + foundBootstrap = true + break + } + } + if !foundBootstrap { + bootstrapKey := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bootstrap"} + bootstrapVal := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + urlKey := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "artifact_base_url"} + urlVal := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: cfg.Bootstrap.ArtifactBaseURL} + bootstrapVal.Content = append(bootstrapVal.Content, urlKey, urlVal) + rootMap.Content = append(rootMap.Content, bootstrapKey, bootstrapVal) + } + } + + out, err := yaml.Marshal(&doc) + if err != nil { + return nil, fmt.Errorf("marshal patched doc: %w", err) + } + return out, nil +} diff --git a/apps/edge/cmd/edge/main_test.go b/apps/edge/cmd/edge/main_test.go index f57e48f..dddd73d 100644 --- a/apps/edge/cmd/edge/main_test.go +++ b/apps/edge/cmd/edge/main_test.go @@ -4,9 +4,12 @@ import ( "bytes" "os" "path/filepath" + "runtime" "strings" "testing" + "gopkg.in/yaml.v3" + "iop/packages/config" "iop/packages/version" ) @@ -413,3 +416,482 @@ func TestAddressForCombinesAdvertiseHostWithListenPort(t *testing.T) { t.Errorf("addressFor empty listen: got %q", got) } } + +func TestConfigCheckRejectsDuplicateNodeTokenAndInvalidConfigs(t *testing.T) { + // Duplicate token test + dir := t.TempDir() + cfgPath := filepath.Join(dir, "edge.yaml") + yamlStr := `server: + listen: "0.0.0.0:9090" +nodes: + - id: "node-a" + alias: "a" + token: "same-token" + adapters: + cli: + enabled: true + - id: "node-b" + alias: "b" + token: "same-token" + adapters: + cli: + enabled: true +` + if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + + root := rootCmd() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"config", "check", "--config", cfgPath}) + if err := root.Execute(); err == nil { + t.Fatalf("expected error due to duplicate token, got nil") + } + + // Invalid runtime concurrency test + yamlStr2 := `server: + listen: "0.0.0.0:9090" +nodes: + - id: "node-a" + alias: "a" + token: "token-a" + adapters: + cli: + enabled: true + runtime: + concurrency: -1 +` + if err := os.WriteFile(cfgPath, []byte(yamlStr2), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + + root = rootCmd() + out.Reset() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"config", "check", "--config", cfgPath}) + if err := root.Execute(); err == nil { + t.Fatalf("expected error due to negative concurrency, got nil") + } + + // Invalid Ollama BaseURL test + yamlStr3 := `server: + listen: "0.0.0.0:9090" +nodes: + - id: "node-a" + alias: "a" + token: "token-a" + adapters: + ollama: + enabled: true + base_url: "" +` + if err := os.WriteFile(cfgPath, []byte(yamlStr3), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + + root = rootCmd() + out.Reset() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"config", "check", "--config", cfgPath}) + if err := root.Execute(); err == nil { + t.Fatalf("expected error due to empty ollama base_url, got nil") + } +} + +func TestNodeRegisterUpsertsYAMLAndOutputsBootstrap(t *testing.T) { + // Mock token generator + origGen := tokenGenerator + defer func() { tokenGenerator = origGen }() + tokenGenerator = func() string { + return "mocked-token-1234" + } + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "edge.yaml") + + // Start with minimal YAML + writeMinimalEdgeYAML(t, cfgPath) + + // 1. Register node-a (new registration) + root := rootCmd() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--alias", "alias-a"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute register node-a: %v\n%s", err, out.String()) + } + + output := out.String() + if !strings.Contains(output, "Registered node node-a") { + t.Errorf("expected 'Registered node node-a', got %q", output) + } + // Check one-line bootstrap output + expectedCmd := "curl -fsSL http://127.0.0.1:18080/bootstrap/node-" + runtime.GOOS + "-" + runtime.GOARCH + ".sh | bash -s mocked-token-1234" + if !strings.Contains(output, expectedCmd) { + t.Errorf("expected bootstrap command %q, got %q", expectedCmd, output) + } + + // Read and parse persisted YAML + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read persisted config: %v", err) + } + var cfg config.EdgeConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + + // Assert exactly 1 node record and generated token persisted + if len(cfg.Nodes) != 1 { + t.Fatalf("expected exactly 1 node, got %d", len(cfg.Nodes)) + } + if cfg.Nodes[0].ID != "node-a" || cfg.Nodes[0].Alias != "alias-a" || cfg.Nodes[0].Token != "mocked-token-1234" { + t.Fatalf("unexpected node state in YAML: %+v", cfg.Nodes[0]) + } + + // 2. Update node-a (explicit token & change adapter to ollama) + root = rootCmd() + out.Reset() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--token", "explicit-token-abc", "--adapter", "ollama", "--ollama-base-url", "http://ollama-host:11434"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute update node-a: %v\n%s", err, out.String()) + } + + output = out.String() + if !strings.Contains(output, "Updated node node-a") { + t.Errorf("expected 'Updated node node-a', got %q", output) + } + expectedCmd2 := "curl -fsSL http://127.0.0.1:18080/bootstrap/node-" + runtime.GOOS + "-" + runtime.GOARCH + ".sh | bash -s explicit-token-abc" + if !strings.Contains(output, expectedCmd2) { + t.Errorf("expected bootstrap command %q, got %q", expectedCmd2, output) + } + + // Read and parse persisted YAML again + data, err = os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read persisted config: %v", err) + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + + // Assert exactly 1 node record and updated token/adapter persisted + if len(cfg.Nodes) != 1 { + t.Fatalf("expected exactly 1 node, got %d", len(cfg.Nodes)) + } + if cfg.Nodes[0].Token != "explicit-token-abc" || !cfg.Nodes[0].Adapters.Ollama.Enabled || cfg.Nodes[0].Adapters.Ollama.BaseURL != "http://ollama-host:11434" { + t.Fatalf("unexpected node state after explicit token update: %+v", cfg.Nodes[0]) + } +} + +func TestNodeRegisterSwitchesCLIToOllama(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "edge.yaml") + + yamlStr := `server: + listen: "0.0.0.0:9090" +nodes: + - id: "node-a" + alias: "a" + token: "token-a" + adapters: + cli: + enabled: true +` + if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + + root := rootCmd() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--adapter", "ollama"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute switch to ollama: %v\n%s", err, out.String()) + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read config: %v", err) + } + var cfg config.EdgeConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if !cfg.Nodes[0].Adapters.Ollama.Enabled { + t.Fatal("expected ollama to be enabled") + } + if cfg.Nodes[0].Adapters.CLI.Enabled { + t.Fatal("expected cli to be disabled") + } + // Preferred path: base_url was set to default + if cfg.Nodes[0].Adapters.Ollama.BaseURL != "http://127.0.0.1:11434" { + t.Fatalf("expected preferred fallback base url, got %q", cfg.Nodes[0].Adapters.Ollama.BaseURL) + } +} + +func TestNodeRegisterRejectsDuplicateAliasAndDuplicateToken(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "edge.yaml") + + yamlStr := `server: + listen: "0.0.0.0:9090" +nodes: + - id: "node-a" + alias: "a" + token: "token-a" + adapters: + cli: + enabled: true +` + if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + + originalBytes, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read original: %v", err) + } + + // 1. Try to register node-b with DUPLICATE alias "a" + root := rootCmd() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-b", "--alias", "a", "--token", "token-b"}) + if err := root.Execute(); err == nil { + t.Fatalf("expected error due to duplicate alias, got nil") + } + + output := out.String() + if strings.Contains(output, "Registered node") || strings.Contains(output, "Updated node") || strings.Contains(output, "curl -fsSL") { + t.Errorf("stdout should not contain success clues on failure, got:\n%s", output) + } + + // Assert YAML file is byte-for-byte unchanged + currentBytes, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read current: %v", err) + } + if !bytes.Equal(originalBytes, currentBytes) { + t.Fatal("expected edge.yaml to remain unchanged on validation failure") + } + + // 2. Try to register node-b with DUPLICATE token "token-a" + root = rootCmd() + out.Reset() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-b", "--alias", "b", "--token", "token-a"}) + if err := root.Execute(); err == nil { + t.Fatalf("expected error due to duplicate token, got nil") + } + + currentBytes2, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read current2: %v", err) + } + if !bytes.Equal(originalBytes, currentBytes2) { + t.Fatal("expected edge.yaml to remain unchanged on validation failure") + } + + // 3. Confirm config check still passes on original file + root = rootCmd() + out.Reset() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"config", "check", "--config", cfgPath}) + if err := root.Execute(); err != nil { + t.Fatalf("config check failed on original valid file: %v", err) + } +} + +func TestNodeRegisterPrintsOneLineBootstrapCommandAndUsesConfiguredArtifactBaseURL(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "edge.yaml") + + // Create minimal config but with configured artifact base url + yamlStr := `server: + listen: "0.0.0.0:9090" +bootstrap: + artifact_base_url: "http://configured-artifact-server:8080" +` + if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + + root := rootCmd() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-test", "--target", "darwin-arm64", "--token", "test-token"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute register: %v\n%s", err, out.String()) + } + + output := out.String() + expectedCmd := "curl -fsSL http://configured-artifact-server:8080/bootstrap/node-darwin-arm64.sh | bash -s test-token" + if !strings.Contains(output, expectedCmd) { + t.Errorf("expected bootstrap command %q, got %q", expectedCmd, output) + } + + // Now override artifact base URL with flag + root = rootCmd() + out.Reset() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-test", "--target", "linux-amd64", "--artifact-base-url", "http://flag-artifact-server:9000", "--token", "test-token-2"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute register override: %v\n%s", err, out.String()) + } + + output = out.String() + expectedCmdOverride := "curl -fsSL http://flag-artifact-server:9000/bootstrap/node-linux-amd64.sh | bash -s test-token-2" + if !strings.Contains(output, expectedCmdOverride) { + t.Errorf("expected bootstrap command %q, got %q", expectedCmdOverride, output) + } +} + +func TestTempFileCLIWorkflowConfigInitNodeRegisterConfigCheck(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "edge.yaml") + t.Chdir(dir) + + // 1. config init + root := rootCmd() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"config", "init", "--output", cfgPath}) + if err := root.Execute(); err != nil { + t.Fatalf("execute init: %v\n%s", err, out.String()) + } + + // 2. node register + root = rootCmd() + out.Reset() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"node", "register", "node-silicon-ollama", "--adapter", "ollama", "--ollama-base-url", "http://127.0.0.1:11434", "--target", "darwin-arm64", "--artifact-base-url", "http://toki-labs.com:18080", "--token", "test-token"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute register: %v\n%s", err, out.String()) + } + + output := out.String() + expectedCmd := "curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s test-token" + if !strings.Contains(output, expectedCmd) { + t.Errorf("expected bootstrap command %q, got %q", expectedCmd, output) + } + + // 3. config check + root = rootCmd() + out.Reset() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"config", "check"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute check: %v\n%s", err, out.String()) + } + + if !strings.Contains(out.String(), "OK edge.yaml") { + t.Fatalf("expected check OK, got %q", out.String()) + } +} + +func TestNodeRegisterPreservesDefaultedNonNodeConfig(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "edge.yaml") + t.Chdir(dir) + + // Write minimal config with server.listen and openai.enabled, omitting other defaults + yamlStr := `server: + listen: "0.0.0.0:9090" +openai: + enabled: true +` + if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + + // Run node register node-a + root := rootCmd() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--token", "token-a"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute register: %v\n%s", err, out.String()) + } + + // 1. Assert original YAML structure still omits the defaulted fields (preserves raw layout) + rawBytes, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read raw config: %v", err) + } + rawStr := string(rawBytes) + badSubstrings := []string{ + "openai:\n listen:", + "openai:\n listen:", + "openai:\n adapter:", + "openai:\n adapter:", + "openai:\n session_id:", + "openai:\n session_id:", + "openai:\n timeout_sec:", + "openai:\n timeout_sec:", + } + for _, sub := range badSubstrings { + // Replace standard newlines with system newlines just in case, but standard is fine. + if strings.Contains(rawStr, sub) || strings.Contains(rawStr, strings.ReplaceAll(sub, "\n", "\r\n")) { + t.Errorf("expected raw edge.yaml to still omit default field, but found explicit %q in YAML:\n%s", sub, rawStr) + } + } + + // 2. Assert effective OpenAI defaults are resolved correctly (not zeroed out) + cfgPtr, err := config.LoadEdge(cfgPath) + if err != nil { + t.Fatalf("load effective config: %v", err) + } + cfg := *cfgPtr + + if cfg.OpenAI.Listen != "0.0.0.0:8080" { + t.Errorf("expected effective openai.listen to remain 0.0.0.0:8080, got %q", cfg.OpenAI.Listen) + } + if cfg.OpenAI.Adapter != "ollama" { + t.Errorf("expected effective openai.adapter to remain ollama, got %q", cfg.OpenAI.Adapter) + } + if cfg.OpenAI.SessionID != "openai" { + t.Errorf("expected effective openai.session_id to remain openai, got %q", cfg.OpenAI.SessionID) + } + if cfg.OpenAI.TimeoutSec != 120 { + t.Errorf("expected effective openai.timeout_sec to remain 120, got %d", cfg.OpenAI.TimeoutSec) + } + if !cfg.OpenAI.StrictOutput { + t.Errorf("expected effective openai.strict_output to remain true") + } + + // 3. Assert nodes[] has the new node and config check passes + if len(cfg.Nodes) != 1 { + t.Fatalf("expected exactly 1 node record, got %d", len(cfg.Nodes)) + } + if cfg.Nodes[0].ID != "node-a" || cfg.Nodes[0].Token != "token-a" { + t.Fatalf("unexpected registered node: %+v", cfg.Nodes[0]) + } + + root = rootCmd() + out.Reset() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"config", "check", "--config", cfgPath}) + if err := root.Execute(); err != nil { + t.Fatalf("config check failed: %v\n%s", err, out.String()) + } +}