feat: architecture refactor foundation - edge service split and remote terminal bridge

This commit is contained in:
toki 2026-06-06 11:47:00 +09:00
parent d842c6a0f1
commit 9c7e5c342d
22 changed files with 2855 additions and 1651 deletions

View file

@ -50,6 +50,10 @@ CLI 실행, specialized agent 등록, bootstrap/enrollment, 원격 터미널 브
- 경로: `agent-roadmap/archive/phase/automation-runtime-bridge/milestones/domain-agent-message-boundary.md`
- 요약: 독립형 실행 전환으로 Edge 직접 domain payload boundary 정리가 현재 범위에서 필요 없어져 폐기한다.
- [진행중] 전역 설계 부채 리팩토링 기반
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- 요약: 원격 터미널 브리지와 이후 운영 확장 전에 Edge, Node, Control Plane, Client, proto/config 경계의 설계 부채를 정리한다.
- [계획] 원격 터미널 브리지 POC
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/remote-terminal-bridge-poc.md`
- 요약: Agent를 설치하기 어려운 host/device를 위해 Edge broker와 Node terminal transport 기반 원격 터미널 브리지 POC를 만든다.

View file

@ -0,0 +1,92 @@
# Milestone: 전역 설계 부채 리팩토링 기반
## 위치
- Roadmap: `agent-roadmap/ROADMAP.md`
- Phase: `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
## 목표
원격 터미널 브리지와 이후 Control Plane/Client 운영 확장을 얹기 전에 현재 코드베이스의 설계 부채를 정리한다.
Edge, Node, Control Plane, Client, proto/config 경계에서 책임이 섞인 부분을 분리하고, 공개 계약과 tracked 예시 설정이 장기 운영 방향을 흐리지 않게 만든다.
## 상태
[진행중]
## 구현 잠금
- 상태: 해제
- 결정 필요: 없음
## 범위
- Edge CLI, Edge service, OpenAI-compatible 입력 표면의 책임 분리
- Node CLI adapter, persistent terminal/SSE 실행기, adapter routing 기본값 정리
- Control Plane HTTP/API handler와 fleet status 호출 구조 정리
- Client 상태/DTO/repository/widget 경계 정리
- proto status/legacy contract와 tracked config/artifact hygiene 정리
- 원격 터미널 브리지 POC가 기대는 terminal/session/event 경계의 선행 리팩토링
## 기능
### Epic: [edge-boundary] Edge Boundary Refactor
Edge 내부에서 CLI command, service orchestration, external compatibility surface가 서로 커지는 것을 막는다.
- [ ] [edge-cmd-split] `apps/edge/cmd/edge/main.go`의 config resolution, bootstrap pack, node registration, YAML patch, smoke client 로직을 기존 동작을 유지한 채 내부 패키지 또는 작은 모듈로 분리한다.
- [ ] [edge-service-split] `apps/edge/internal/service`를 run dispatch, node command, control command, capability/status provider 책임으로 분리하고 health simulation 같은 임시 command 구현을 실제 상태 조회 경계와 분리한다.
- [ ] [openai-surface-split] `apps/edge/internal/openai`에서 HTTP lifecycle, chat request mapping, run stream/result collection, strict output policy, Ollama passthrough를 분리해 OpenAI-compatible 표면이 IOP native 기능을 흡수하지 않게 한다.
- [x] [event-bus-contract] Edge event bus의 drop, replay, immutability, future persistence 정책을 코드 경계와 테스트로 명확히 한다. 검증: `go test ./apps/edge/internal/events` 통과.
### Epic: [node-runtime] Node Runtime Boundary
Node CLI automation runtime을 remote terminal bridge가 재사용할 수 있는 단위와 provider-specific 실행 단위로 나눈다.
- [ ] [cli-executor-split] `apps/node/internal/adapters/cli`의 mode dispatch와 session map 소유를 executor/strategy 경계로 분리한다.
- [ ] [terminal-core] persistent PTY 실행, screen rendering, resize/input/signal/close 기반이 될 terminal session core를 provider-specific Claude/OpenCode 처리와 분리한다.
- [ ] [router-default] Node adapter registry가 mock 기본값으로 오류를 숨기지 않도록 production 실행 경로의 empty adapter 처리 정책을 명시하고 적용한다.
- [ ] [vllm-surface] vLLM adapter skeleton을 명확히 experimental/disabled로 낮추거나 실행 가능한 streaming adapter로 승격한다.
### Epic: [cp-client] Control Plane and Client Boundary
Control Plane과 Flutter Client가 multi-edge 운영면으로 커질 때 선형 지연과 상태 결합이 커지지 않게 한다.
- [ ] [cp-api-split] `apps/control-plane/cmd/control-plane/main.go`의 server lifecycle, HTTP handler, DTO conversion, Edge command/fleet orchestration을 분리한다.
- [ ] [fleet-polling] `/fleet/status`와 fleet command 호출을 bounded concurrency, cache, timeout 정책으로 정리한다.
- [ ] [client-state-split] Flutter client의 bootstrap, status state, DTO, repository, panel widget 경계를 분리하고 Firebase/Mattermost 같은 외부 통합은 선택적으로 초기화되게 한다.
### Epic: [contract-hygiene] Contract and Repository Hygiene
공개 계약과 tracked 예시 파일이 실제 운영 원칙과 어긋나지 않게 정리한다.
- [ ] [status-config-summary] Control Plane status contract에서 full `NodeConfigPayload` 노출 여지를 제거하거나 summary-only DTO로 고정한다.
- [ ] [proto-legacy] legacy scheduling/node registration proto placeholder를 deprecated/reserved/remove 중 하나로 정리하고 생성 코드와 call-site를 맞춘다.
- [ ] [sample-config] tracked `configs/*.yaml`에서 로컬 IP, host명, token, 위험한 bypass flag, 개인 경로를 제거하고 안전한 example/default 기준으로 재정리한다.
- [ ] [git-artifacts] tracked Flutter build artifact를 제거하고 package build 산출물이 다시 추적되지 않도록 ignore 규칙을 보강한다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 아직 충족되지 않았다.
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
## 범위 제외
- 원격 터미널 브리지 open/input/output/resize/signal/close 기능 구현
- 새로운 terminal transport(SSH/WinRM/serial) 구현
- OpenAI Responses API, A2A streaming, provider 품질 최적화 같은 신규 제품 기능
- 정책/audit 상세 schema 확정
- 완료된 archive 문서 재포맷
## 작업 컨텍스트
- 관련 경로: `apps/edge`, `apps/node`, `apps/control-plane`, `apps/client`, `packages/flutter/iop_console`, `proto/iop`, `configs`, `.gitignore`
- 표준선(선택): Control Plane은 Edge를 통해 관찰/제어하고, Edge는 Node 실행 그룹 상태를 소유하며, Node는 adapter execution과 terminal transport 실행자 역할을 유지한다. OpenAI-compatible/A2A 표면은 IOP native terminal 제어를 흡수하지 않는다.
- 선행 작업: 설계 부채 색인 리뷰
- 후속 작업: 원격 터미널 브리지 POC
- 확인 필요: 없음

View file

@ -0,0 +1,166 @@
<!-- task=m-architecture-refactor-foundation/01_edge_cmd_split plan=0 tag=REFACTOR -->
# Code Review Reference - REFACTOR
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed 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-06
task=m-architecture-refactor-foundation/01_edge_cmd_split, plan=0, tag=REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- Task ids:
- `edge-cmd-split`: `apps/edge/cmd/edge/main.go`의 config resolution, bootstrap pack, node registration, YAML patch, smoke client 로직을 기존 동작을 유지한 채 내부 패키지 또는 작은 모듈로 분리한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[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-architecture-refactor-foundation/01_edge_cmd_split/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-architecture-refactor-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] Extract Edge Command Support Modules | [x] |
## 구현 체크리스트
- [x] `apps/edge/cmd/edge/main.go`에서 Cobra root/main/serve/console wiring만 남기고 config/env/bootstrap/node-register/YAML patch/smoke helper를 `apps/edge/internal/edgecmd` 같은 내부 package의 작은 파일들로 이동한다.
- [x] 기존 command help, flags, default config discovery, one-line bootstrap output, YAML preservation, smoke client 응답 검증을 유지하도록 `apps/edge/cmd/edge/main_test.go` 테스트를 새 package boundary에 맞게 옮기거나 조정한다.
- [x] `go test ./apps/edge/cmd/edge`와 `go test ./apps/edge/...`를 실행해 command package와 edge 전체 회귀를 확인한다.
- [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하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/01_edge_cmd_split/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/01_edge_cmd_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] 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가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
계획대로 `apps/edge/internal/edgecmd` 패키지로 command support 로직을 분리했다.
- `main.go`는 `edgecmd.NewRoot()` 호출과 `serve`/`console` runtime config 조립만 남겼다.
- `edgecmd`는 root/help/version/config/env/setup/bootstrap/node/nodes/smoke command와 config discovery, bootstrap pack, YAML patch, smoke client helper를 소유한다.
- helper 직접 호출 테스트는 `apps/edge/internal/edgecmd/edgecmd_test.go`로 이동했고, `main_test.go`는 root command surface와 command-level 회귀 중심으로 유지했다.
## 주요 설계 결정
- `serve`/`console` command는 Edge runtime bootstrap과 interactive console을 직접 호출해야 하므로 main package에 남기고, 공통 config/log/artifact path 해석은 `edgecmd.LoadEdgeConfig`, `edgecmd.ResolveEdgeLogPath`, `edgecmd.ResolveBootstrapArtifactDir`로 호출하게 했다.
- `edgecmd.NewRoot`는 main package가 제공하는 `serve`/`console` command를 받아 기존 command surface와 help flow를 유지한다.
- YAML patch와 bootstrap command 출력은 기존 보존형 동작을 유지했고, moved helper tests로 artifact URL derivation, node register upsert, log path fallback, bootstrap YAML patch를 직접 검증한다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `main.go`가 Cobra/root/serve/console wiring 중심으로 얇아졌는지 확인한다.
- command 이름, flags, help flow, bootstrap one-line command 출력이 유지되는지 확인한다.
- YAML patch가 기존 non-node/defaulted sections를 새로 materialize하지 않는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REFACTOR-1 중간 검증
```bash
$ go test ./apps/edge/cmd/edge
ok iop/apps/edge/cmd/edge 0.026s
```
### 최종 검증
```bash
$ go test ./apps/edge/...
ok iop/apps/edge/cmd/edge 0.027s
ok iop/apps/edge/internal/bootstrap (cached)
ok iop/apps/edge/internal/controlplane (cached)
ok iop/apps/edge/internal/edgecmd 0.007s
ok iop/apps/edge/internal/events (cached)
ok iop/apps/edge/internal/input (cached)
ok iop/apps/edge/internal/input/a2a (cached)
ok iop/apps/edge/internal/node (cached)
ok iop/apps/edge/internal/openai (cached)
ok iop/apps/edge/internal/opsconsole (cached)
ok iop/apps/edge/internal/service (cached)
ok iop/apps/edge/internal/transport (cached)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS - `complete.log`를 작성하고 active task 디렉터리를 archive로 이동한다.
### 리뷰 근거
- `main.go`는 `edgecmd.NewRoot`와 `loadRuntimeConfig` 중심으로 축소되었고, serve/console wiring만 main package에 남았다.
- config/env/bootstrap/node-register/YAML patch/nodes/smoke 구현은 `apps/edge/internal/edgecmd`로 이동했으며 기존 command surface와 helper 테스트가 유지되었다.
- 검증 재실행:
- `go test -count=1 ./apps/edge/cmd/edge` 통과
- `go test -count=1 ./apps/edge/...` 통과
- `make pack-node-target NODE_TARGET=$(go env GOOS)-$(go env GOARCH)` 통과
- `./scripts/e2e-smoke.sh` 통과

View file

@ -0,0 +1,44 @@
# Complete - m-architecture-refactor-foundation/01_edge_cmd_split
## 완료 일시
2026-06-06
## 요약
Edge command support split review loop 1 completed with final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | `apps/edge/cmd/edge/main.go` was reduced to root/serve/console wiring while command support logic moved to `apps/edge/internal/edgecmd`; no Required or Suggested findings. |
## 구현/정리 내용
- Split config discovery, env reporting, setup, bootstrap pack, node registration/YAML patch, nodes list, version, and OpenAI smoke command support into `apps/edge/internal/edgecmd`.
- Kept `serve` and `console` runtime entrypoints in `apps/edge/cmd/edge/main.go`, using `edgecmd.LoadEdgeConfig`, `edgecmd.ResolveEdgeLogPath`, and `edgecmd.ResolveBootstrapArtifactDir`.
- Moved helper-level tests into `apps/edge/internal/edgecmd/edgecmd_test.go` while keeping command surface regression tests in `apps/edge/cmd/edge/main_test.go`.
## 최종 검증
- `go test -count=1 ./apps/edge/cmd/edge` - PASS; `ok iop/apps/edge/cmd/edge 0.035s`.
- `go test -count=1 ./apps/edge/...` - PASS; all edge packages passed, including `apps/edge/internal/edgecmd`.
- `make pack-node-target NODE_TARGET=$(go env GOOS)-$(go env GOARCH)` - PASS; built host edge binary, built `iop-node-linux-arm64`, and wrote bootstrap artifacts to `build/artifacts`.
- `cd build/artifacts/linux-arm64 && sha256sum -c SHA256SUMS` - PASS; `iop-node: OK`.
- `./scripts/e2e-smoke.sh` - PASS; auxiliary mock smoke verified node registration, `/nodes`, `/capabilities`, `/transport`, two foreground messages, background run, `/sessions`, and `/terminate-session`.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- Completed task ids:
- `edge-cmd-split`: PASS; evidence=`agent-task/archive/2026/06/m-architecture-refactor-foundation/01_edge_cmd_split/plan_local_G05_0.log`, `agent-task/archive/2026/06/m-architecture-refactor-foundation/01_edge_cmd_split/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/cmd/edge`, `go test -count=1 ./apps/edge/...`, `make pack-node-target NODE_TARGET=$(go env GOOS)-$(go env GOARCH)`, `./scripts/e2e-smoke.sh`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,159 @@
<!-- task=m-architecture-refactor-foundation/01_edge_cmd_split plan=0 tag=REFACTOR -->
# Edge Cmd Split Plan
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션 작성까지가 구현의 일부다. 코드 변경 후 검증을 실행하고 실제 변경 내용, 검증 출력, 계획 대비 변경 사항을 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, 범위 충돌 때문에 진행할 수 없으면 review stub의 `사용자 리뷰 요청`에 근거와 재개 조건을 적고 중단한다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 최종 판정, log archive, `complete.log` 작성은 code-review 전용이다.
## 배경
현재 `apps/edge/cmd/edge/main.go`는 Cobra root, config discovery, bootstrap pack, node registration, YAML patch, OpenAI smoke client까지 1500라인 이상을 한 파일에서 처리한다. Edge domain rule은 공식 local/field UX를 `iop-edge` command 표면에 모으라고 하지만, command entrypoint가 구현 세부까지 소유하면 후속 command 확장과 테스트가 계속 커진다. 이 작업은 동작과 help text를 유지하면서 command support 로직을 내부 패키지로 분리한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active `CODE_REVIEW-local-G05.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 사용자 프롬프트를 만들지 않으며, code-review가 요청의 타당성을 판단하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- Task ids:
- `edge-cmd-split`: `apps/edge/cmd/edge/main.go`의 config resolution, bootstrap pack, node registration, YAML patch, smoke client 로직을 기존 동작을 유지한 채 내부 패키지 또는 작은 모듈로 분리한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- `apps/edge/cmd/edge/main.go`
- `apps/edge/cmd/edge/main_test.go`
### 테스트 환경 규칙
- `test_env=local`; `agent-test/local/rules.md`를 읽었다.
- matching profile: `agent-test/local/edge-smoke.md`.
- 적용 명령: 변경한 edge 패키지 또는 `go test ./apps/edge/...`; command-only 변경은 `go test ./apps/edge/cmd/edge`와 최종 `go test ./apps/edge/...`로 검증한다.
- OpenAI-compatible 런타임 smoke는 실제 endpoint가 필요하므로 이 리팩토링의 필수 조건은 아니다. command smoke client 동작은 `httptest` 기반 `TestSmokeOpenAICommandSuccess/Failure`로 확인한다.
### 테스트 커버리지 공백
- Config discovery, config init/check, env output, bootstrap pack, node register YAML patch, nodes list, OpenAI smoke command는 `apps/edge/cmd/edge/main_test.go`에 기존 회귀 테스트가 있다.
- 새 내부 package boundary 자체는 아직 없다. 구현 시 이동한 helper별 테스트를 새 package 또는 기존 command test로 유지해야 한다.
### 심볼 참조
- `resolveConfigPath`, `resolveEdgeLogPath`, `resolveBootstrapArtifactDir`, `renderEnv`, `packNodeBootstrap`, `validateEdgeConfig`, `nodeRegisterCmd`, `patchYAML`, `resolveSmokeBaseURL`, `smokeOpenAICmd` 참조는 `apps/edge/cmd/edge/main.go`와 `apps/edge/cmd/edge/main_test.go`에 한정된다.
- repo 외부 공개 API는 없다. 이동 후 main package tests that depend on unexported helpers must move with the helpers or test via root command.
### 분할 판단
- shared task group: `agent-task/m-architecture-refactor-foundation/`.
- sibling subtasks: `01_edge_cmd_split` independent, `02_edge_service_split` independent, `03+02_openai_surface_split` depends on service split.
- 이 plan은 CLI command boundary만 다루며 service/openai 코드와 독립 검증 가능하므로 첫 subtask로 분리한다.
### 범위 결정 근거
- `apps/edge/internal/service/**`, `apps/edge/internal/openai/**`, `apps/edge/internal/events/**`는 이 plan에서 수정하지 않는다.
- 사용자-facing command 이름, flags, help flow, bootstrap one-line command 형식은 유지한다.
- 새 외부 dependency는 추가하지 않는다.
### 빌드 등급
- `local-G05`: 파일 이동과 package boundary 정리가 크지만 동작 보존형이고 기존 Go 테스트가 촘촘해 local 검증으로 충분하다.
## 구현 체크리스트
- [ ] `apps/edge/cmd/edge/main.go`에서 Cobra root/main/serve/console wiring만 남기고 config/env/bootstrap/node-register/YAML patch/smoke helper를 `apps/edge/internal/edgecmd` 같은 내부 package의 작은 파일들로 이동한다.
- [ ] 기존 command help, flags, default config discovery, one-line bootstrap output, YAML preservation, smoke client 응답 검증을 유지하도록 `apps/edge/cmd/edge/main_test.go` 테스트를 새 package boundary에 맞게 옮기거나 조정한다.
- [ ] `go test ./apps/edge/cmd/edge`와 `go test ./apps/edge/...`를 실행해 command package와 edge 전체 회귀를 확인한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REFACTOR-1] Extract Edge Command Support Modules
#### 문제
- [main.go](/config/workspace/iop/apps/edge/cmd/edge/main.go:42)는 root command wiring에서 시작해 config/env/bootstrap/node/smoke 구현을 같은 package/file에 둔다.
- [main.go](/config/workspace/iop/apps/edge/cmd/edge/main.go:513), [main.go](/config/workspace/iop/apps/edge/cmd/edge/main.go:914), [main.go](/config/workspace/iop/apps/edge/cmd/edge/main.go:1357)는 bootstrap pack, node register, smoke client 구현이 각각 수백 라인으로 command entrypoint에 붙어 있다.
#### 해결 방법
Before:
```go
// apps/edge/cmd/edge/main.go:42
func rootCmd() *cobra.Command {
root := &cobra.Command{Use: "edge", ...}
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (defaults to bundle-local edge.yaml)")
root.AddCommand(serveCmd(), consoleCmd(), versionCmd(), configCmd(), envCmd(), setupCmd(), bootstrapCmd(), nodeCmd(), nodesCmd(), smokeCmd())
return root
}
```
After:
```go
// apps/edge/cmd/edge/main.go
func rootCmd() *cobra.Command {
root := edgecmd.NewRoot(edgecmd.Options{
ConfigPath: &cfgFile,
Serve: serveCmd(),
Console: consoleCmd(),
})
return root
}
```
- `apps/edge/internal/edgecmd/config.go`: config discovery, config init/print/check, validation.
- `apps/edge/internal/edgecmd/env.go`: advertise host, derived URLs, env report rendering.
- `apps/edge/internal/edgecmd/bootstrap.go`: bootstrap pack, checksum, script rendering, artifact refresh.
- `apps/edge/internal/edgecmd/node_register.go`: node register flag state, config mutation, YAML patch.
- `apps/edge/internal/edgecmd/smoke_openai.go`: smoke openai client DTOs and checks.
- Keep command behavior stable; only package/file ownership changes.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/cmd/edge/main.go`
- [ ] `apps/edge/cmd/edge/main_test.go`
- [ ] `apps/edge/internal/edgecmd/*.go`
- [ ] `apps/edge/internal/edgecmd/*_test.go`
#### 테스트 작성
- 기존 command tests를 이동한 helper package와 root command package에 맞춰 유지한다.
- `TestRootCmdIncludesOperationalCommands`, config/env/bootstrap/node-register/smoke tests must still cover the user-visible command surface.
#### 중간 검증
```bash
go test ./apps/edge/cmd/edge
```
Expected: command package tests pass.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/cmd/edge/main.go` | REFACTOR-1 |
| `apps/edge/cmd/edge/main_test.go` | REFACTOR-1 |
| `apps/edge/internal/edgecmd/*.go` | REFACTOR-1 |
| `apps/edge/internal/edgecmd/*_test.go` | REFACTOR-1 |
## 최종 검증
```bash
go test ./apps/edge/...
```
Expected: all edge package tests pass. Go test cache output is acceptable for unchanged packages; use `-count=1` only if a command behavior assertion is flaky or suspected stale.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,126 @@
<!-- task=m-architecture-refactor-foundation/02_edge_service_split plan=0 tag=REFACTOR -->
# Code Review Reference - REFACTOR
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed 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-06
task=m-architecture-refactor-foundation/02_edge_service_split, plan=0, tag=REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- Task ids:
- `edge-service-split`: `apps/edge/internal/service`를 run dispatch, node command, control command, capability/status provider 책임으로 분리하고 health simulation 같은 임시 command 구현을 실제 상태 조회 경계와 분리한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G05.md` -> `code_review_cloud_G05_N.log`, `PLAN-cloud-G05.md` -> `plan_cloud_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/02_edge_service_split/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-architecture-refactor-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] Split Service Responsibilities | [ ] |
## 구현 체크리스트
- [ ] `apps/edge/internal/service`를 같은 package 안에서 `service.go`, `status_provider.go`, `run_dispatch.go`, `node_command.go`, `control_command.go` 같은 책임별 파일로 나눈다.
- [ ] `ExecuteCommand("health.check")``time.Sleep` 기반 임시 성공을 제거하고 `ListNodeSnapshots`/`GetCapabilities` 기반 `HealthStatus` 또는 equivalent private boundary를 사용하게 한다.
- [ ] 기존 public service API를 유지하고 openai/A2A/opsconsole/controlplane call-site 변경이 없거나 mechanical import-only 수준인지 확인한다.
- [ ] `go test ./apps/edge/internal/service``go test ./apps/edge/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G05_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G05_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-architecture-refactor-foundation/02_edge_service_split/``agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/02_edge_service_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G05.md``CODE_REVIEW-cloud-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가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `Service` public API compatibility가 유지되는지 확인한다.
- `health.check`가 fixed sleep/fake success가 아니라 service status boundary에서 파생되는지 확인한다.
- controlplane connector, opsconsole, A2A/OpenAI compile/test가 유지되는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REFACTOR-1 중간 검증
```bash
$ go test ./apps/edge/internal/service
(output)
```
### 최종 검증
```bash
$ go test ./apps/edge/...
(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.

View file

@ -0,0 +1,167 @@
<!-- task=m-architecture-refactor-foundation/02_edge_service_split plan=0 tag=REFACTOR -->
# Edge Service Split Plan
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-cloud-G05.md`의 구현 에이전트 소유 섹션 작성까지가 구현의 일부다. 코드 변경 후 검증을 실행하고 실제 변경 내용, 검증 출력, 계획 대비 변경 사항을 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, 범위 충돌 때문에 진행할 수 없으면 review stub의 `사용자 리뷰 요청`에 근거와 재개 조건을 적고 중단한다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 최종 판정, log archive, `complete.log` 작성은 code-review 전용이다.
## 배경
`apps/edge/internal/service/service.go`는 node snapshot/status provider, run dispatch, cancel/session commands, node command transport, Ollama passthrough, Control Plane command execution을 한 파일에서 처리한다. Control Plane connector와 console/openai/A2A 입력 표면이 service를 공유하므로, service 내부 책임 경계가 흐리면 후속 terminal bridge와 운영 command 확장이 서로 얽힌다. 이 작업은 service package API를 보존하면서 파일/헬퍼 경계를 나누고, `health.check`의 임시 sleep 성공을 실제 Edge-owned status 요약 경계로 대체한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active `CODE_REVIEW-cloud-G05.md``사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 사용자 프롬프트를 만들지 않으며, code-review가 요청의 타당성을 판단하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- Task ids:
- `edge-service-split`: `apps/edge/internal/service`를 run dispatch, node command, control command, capability/status provider 책임으로 분리하고 health simulation 같은 임시 command 구현을 실제 상태 조회 경계와 분리한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/service_test.go`
### 테스트 환경 규칙
- `test_env=local`; `agent-test/local/rules.md`를 읽었다.
- matching profile: `agent-test/local/edge-smoke.md`.
- 적용 명령: 변경한 edge 패키지 또는 `go test ./apps/edge/...`.
- service는 registry/command/status boundary라 focused `go test ./apps/edge/internal/service` 뒤에 connector/console/input call-site 포함 `go test ./apps/edge/...`를 실행한다.
### 테스트 커버리지 공백
- `BuildRunRequest`, `ResolveNode`, `ListNodeSnapshots`, capabilities/domain agents, `ExecuteCommand`, node command success/error는 기존 `service_test.go`가 다룬다.
- `health.check`는 현재 event sequence/status 정도만 검증하며 실제 status summary 내용은 약하다. 구현 시 `health.check`가 registry/capability snapshot을 사용한다는 테스트를 추가한다.
### 심볼 참조
- `SubmitRun`, `RunHandle`, `RunDispatch`, `RunStream`은 openai, A2A, opsconsole, tests에서 참조된다.
- `ListNodeSnapshots`, `GetCapabilities`, `ExecuteCommand`는 controlplane connector status provider interface에서 참조된다.
- `NodeCommandView`, `UsageStatus`, `Capabilities`, `SessionList`, `TransportStatus`는 opsconsole status rendering에서 참조된다.
- public service method/type names should not be renamed in this task.
### 분할 판단
- shared task group: `agent-task/m-architecture-refactor-foundation/`.
- sibling subtasks: `01_edge_cmd_split` independent, `02_edge_service_split` independent, `03+02_openai_surface_split` waits for this task.
- 이 plan은 service package 내부의 responsibility split이며, openai split은 service API/stream semantics와 충돌할 수 있어 후속 dependent task로 둔다.
### 범위 결정 근거
- `apps/edge/internal/openai/**``apps/edge/cmd/edge/**`는 이 plan에서 수정하지 않는다.
- service package import path와 public method/type names는 유지한다.
- Control Plane proto 계약, node transport proto, registry implementation은 변경하지 않는다.
### 빌드 등급
- `cloud-G05`: control command/status provider 의미를 정리하고 여러 입력 표면이 의존하는 service 경계를 다루므로 architecture judgment와 regression review가 중요하다.
## 구현 체크리스트
- [ ] `apps/edge/internal/service`를 같은 package 안에서 `service.go`, `status_provider.go`, `run_dispatch.go`, `node_command.go`, `control_command.go` 같은 책임별 파일로 나눈다.
- [ ] `ExecuteCommand("health.check")``time.Sleep` 기반 임시 성공을 제거하고 `ListNodeSnapshots`/`GetCapabilities` 기반 `HealthStatus` 또는 equivalent private boundary를 사용하게 한다.
- [ ] 기존 public service API를 유지하고 openai/A2A/opsconsole/controlplane call-site 변경이 없거나 mechanical import-only 수준인지 확인한다.
- [ ] `go test ./apps/edge/internal/service``go test ./apps/edge/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REFACTOR-1] Split Service Responsibilities
#### 문제
- [service.go](/config/workspace/iop/apps/edge/internal/service/service.go:24)는 service dependencies를 정의한 뒤 같은 파일에서 status provider, run dispatch, node commands, control commands까지 이어진다.
- [service.go](/config/workspace/iop/apps/edge/internal/service/service.go:609)는 `ExecuteCommand`가 health simulation, agent status, node command routing을 한 switch에 모으고, [service.go](/config/workspace/iop/apps/edge/internal/service/service.go:619)는 `time.Sleep`으로 임시 health check 성공을 만든다.
#### 해결 방법
Before:
```go
// apps/edge/internal/service/service.go:609
func (s *Service) ExecuteCommand(ctx context.Context, req *iop.EdgeCommandRequest, onEvent func(*iop.EdgeCommandEvent)) (*iop.EdgeCommandResponse, error) {
switch req.Operation {
case "health.check":
onEvent(started)
time.Sleep(10 * time.Millisecond) // simulation
onEvent(completed)
return &iop.EdgeCommandResponse{Status: "completed", Summary: "Edge health check OK"}, nil
...
}
}
```
After:
```go
// apps/edge/internal/service/control_command.go
func (s *Service) ExecuteCommand(ctx context.Context, req *iop.EdgeCommandRequest, onEvent func(*iop.EdgeCommandEvent)) (*iop.EdgeCommandResponse, error) {
return s.controlCommands().Execute(ctx, req, onEvent)
}
// apps/edge/internal/service/status_provider.go
func (s *Service) HealthStatus() HealthStatusView {
return HealthStatusView{Nodes: s.ListNodeSnapshots(), Capabilities: s.GetCapabilities()}
}
```
- Keep the receiver methods on `*Service` for compatibility.
- Move private helpers near their responsibility; keep shared helpers such as `normalizeTimeoutSec` in a small common file.
- Add a service test that constructs registry state and asserts `health.check` summary/events are derived from service status, not a fixed fake delay.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/service/service.go`
- [ ] `apps/edge/internal/service/status_provider.go`
- [ ] `apps/edge/internal/service/run_dispatch.go`
- [ ] `apps/edge/internal/service/node_command.go`
- [ ] `apps/edge/internal/service/control_command.go`
- [ ] `apps/edge/internal/service/service_test.go` or split `*_test.go` files
#### 테스트 작성
- Add or update service tests for `health.check` status summary.
- Existing node command and run dispatch tests must remain green without changing external call sites.
#### 중간 검증
```bash
go test ./apps/edge/internal/service
```
Expected: service tests pass and cover the new status-backed health command.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/service.go` | REFACTOR-1 |
| `apps/edge/internal/service/status_provider.go` | REFACTOR-1 |
| `apps/edge/internal/service/run_dispatch.go` | REFACTOR-1 |
| `apps/edge/internal/service/node_command.go` | REFACTOR-1 |
| `apps/edge/internal/service/control_command.go` | REFACTOR-1 |
| `apps/edge/internal/service/*_test.go` | REFACTOR-1 |
## 최종 검증
```bash
go test ./apps/edge/...
```
Expected: all edge package tests pass. Go test cache output is acceptable for unchanged packages; use `-count=1` if service/control command behavior appears stale.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,126 @@
<!-- task=m-architecture-refactor-foundation/03+02_openai_surface_split plan=0 tag=REFACTOR -->
# Code Review Reference - REFACTOR
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed 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-06
task=m-architecture-refactor-foundation/03+02_openai_surface_split, plan=0, tag=REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- Task ids:
- `openai-surface-split`: `apps/edge/internal/openai`에서 HTTP lifecycle, chat request mapping, run stream/result collection, strict output policy, Ollama passthrough를 분리해 OpenAI-compatible 표면이 IOP native 기능을 흡수하지 않게 한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G05.md` -> `code_review_cloud_G05_N.log`, `PLAN-cloud-G05.md` -> `plan_cloud_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/03+02_openai_surface_split/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-architecture-refactor-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] Split OpenAI-Compatible Surface Responsibilities | [ ] |
## 구현 체크리스트
- [ ] Confirm predecessor `02_edge_service_split` has `complete.log` before implementation; do not start from this plan while predecessor is active/incomplete.
- [ ] Split `apps/edge/internal/openai/server.go` into lifecycle/routes, chat request mapping, run collection/SSE streaming, strict output policy, Ollama passthrough, HTTP response/types files while keeping package API stable.
- [ ] Keep OpenAI-compatible behavior unchanged: request validation, model target resolution, strict output policy, SSE chunks, Ollama passthrough status/content-type/body.
- [ ] `go test ./apps/edge/internal/openai``go test ./apps/edge/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G05_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G05_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-architecture-refactor-foundation/03+02_openai_surface_split/``agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/03+02_openai_surface_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G05.md``CODE_REVIEW-cloud-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가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- 구현이 predecessor `02_edge_service_split` 완료 후 진행되었는지 확인한다.
- HTTP lifecycle/routes, chat mapping, stream collection, strict output, Ollama passthrough가 파일 경계로만 분리되고 behavior가 유지되는지 확인한다.
- OpenAI-compatible surface에 native terminal/control 기능이 섞이지 않았는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REFACTOR-1 중간 검증
```bash
$ go test ./apps/edge/internal/openai
(output)
```
### 최종 검증
```bash
$ go test ./apps/edge/...
(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.

View file

@ -0,0 +1,187 @@
<!-- task=m-architecture-refactor-foundation/03+02_openai_surface_split plan=0 tag=REFACTOR -->
# OpenAI Surface Split Plan
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-cloud-G05.md`의 구현 에이전트 소유 섹션 작성까지가 구현의 일부다. 코드 변경 후 검증을 실행하고 실제 변경 내용, 검증 출력, 계획 대비 변경 사항을 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, 범위 충돌 때문에 진행할 수 없으면 review stub의 `사용자 리뷰 요청`에 근거와 재개 조건을 적고 중단한다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 최종 판정, log archive, `complete.log` 작성은 code-review 전용이다.
## 배경
`apps/edge/internal/openai/server.go`는 HTTP lifecycle, route handlers, chat request mapping, run result collection, SSE streaming, strict output normalization, Ollama passthrough DTO를 한 파일에 담고 있다. OpenAI-compatible 표면은 edge inbound adapter여야 하며 IOP native 기능을 흡수하지 않아야 한다. 이 작업은 service split 완료 후 OpenAI package 내부 파일 경계를 나누고, behavior는 기존 tests로 보존한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active `CODE_REVIEW-cloud-G05.md``사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 사용자 프롬프트를 만들지 않으며, code-review가 요청의 타당성을 판단하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- Task ids:
- `openai-surface-split`: `apps/edge/internal/openai`에서 HTTP lifecycle, chat request mapping, run stream/result collection, strict output policy, Ollama passthrough를 분리해 OpenAI-compatible 표면이 IOP native 기능을 흡수하지 않게 한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/service_test.go`
### 테스트 환경 규칙
- `test_env=local`; `agent-test/local/rules.md`를 읽었다.
- matching profile: `agent-test/local/edge-smoke.md`.
- 적용 명령: OpenAI-compatible 경계 변경은 focused `go test ./apps/edge/internal/openai`와 최종 `go test ./apps/edge/...`를 실행한다.
- 실제 `iop-edge smoke openai`는 live edge/node endpoint가 필요해 이 리팩토링의 기본 필수 명령으로 두지 않는다. package tests use `httptest` and fake service.
### 테스트 커버리지 공백
- Existing tests cover configured target dispatch, request model fallback, Ollama options passthrough, SSE streaming, reasoning content, strict output normalization/wrapping/buffered streaming/live streaming, model list, Ollama API passthrough, collect timeout.
- HTTP lifecycle route registration is lightly covered through direct handler calls, not `Start`; implementation should avoid changing lifecycle behavior beyond moving code.
### 심볼 참조
- `NewServer` and `Start` are used by `apps/edge/internal/input/manager.go`.
- `handleChatCompletions`, `handleOllamaAPI`, `collectRunResult`, `streamChatCompletion`, and strict output helpers are package-private and used by `server.go`/`server_test.go`.
- No public API rename is planned. Package-private helpers may move files but should keep names unless tests are updated in the same package.
### 분할 판단
- shared task group: `agent-task/m-architecture-refactor-foundation/`.
- sibling subtasks: `01_edge_cmd_split` independent, `02_edge_service_split` independent, `03+02_openai_surface_split` depends on predecessor `02_edge_service_split`.
- predecessor resolution: `02_edge_service_split` has active plan files and no `complete.log` yet. Runtime must wait for `agent-task/m-architecture-refactor-foundation/02_edge_service_split/complete.log` or its archived equivalent before implementing this plan.
### 범위 결정 근거
- Do not add Responses API, A2A streaming, new provider quality behavior, or native terminal control to OpenAI-compatible routes.
- Do not change `edgeservice.RunHandle` semantics in this task; consume the service API produced by `02_edge_service_split`.
- Do not modify proto/config contracts.
### 빌드 등급
- `cloud-G05`: user-visible HTTP/SSE behavior and strict output normalization are easy to regress despite package-only refactor, so review needs careful semantic comparison.
## 구현 체크리스트
- [ ] Confirm predecessor `02_edge_service_split` has `complete.log` before implementation; do not start from this plan while predecessor is active/incomplete.
- [ ] Split `apps/edge/internal/openai/server.go` into lifecycle/routes, chat request mapping, run collection/SSE streaming, strict output policy, Ollama passthrough, HTTP response/types files while keeping package API stable.
- [ ] Keep OpenAI-compatible behavior unchanged: request validation, model target resolution, strict output policy, SSE chunks, Ollama passthrough status/content-type/body.
- [ ] `go test ./apps/edge/internal/openai``go test ./apps/edge/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- Directory dependency source: `03+02_openai_surface_split` depends on sibling index `02`.
- Required predecessor: `agent-task/m-architecture-refactor-foundation/02_edge_service_split/complete.log` or matching archived `complete.log`.
- Current status at plan creation: missing; predecessor has active plan/review files only.
### [REFACTOR-1] Split OpenAI-Compatible Surface Responsibilities
#### 문제
- [server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:45)는 lifecycle and route registration을 소유하고, 같은 파일의 [server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:119)는 chat request mapping and dispatch, [server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:259)는 SSE streaming, [server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:676)는 strict output normalization을 함께 담는다.
- [server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:191)의 Ollama passthrough도 같은 file에 있어 OpenAI-compatible adapter 책임이 커지고 있다.
#### 해결 방법
Before:
```go
// apps/edge/internal/openai/server.go:45
func (s *Server) Start(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealthz)
mux.HandleFunc("/v1/models", s.handleModels)
mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions)
mux.HandleFunc("/api/", s.handleOllamaAPI)
...
}
```
After:
```go
// apps/edge/internal/openai/server.go
func (s *Server) Start(ctx context.Context) error {
mux := s.routes()
return s.serve(ctx, mux)
}
// apps/edge/internal/openai/routes.go
func (s *Server) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealthz)
mux.HandleFunc("/v1/models", s.handleModels)
mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions)
mux.HandleFunc("/api/", s.handleOllamaAPI)
return mux
}
```
- `chat_handler.go`: request validation, prompt mapping, `SubmitRun`.
- `run_result.go`: `collectRunResult`, timeout/error mapping.
- `stream.go`: SSE response writing and streaming collectors.
- `strict_output.go`: XML/strict policy inference and normalization.
- `ollama_passthrough.go`: `/api/` passthrough.
- `types.go`: OpenAI request/response DTOs.
- Keep tests in package `openai`; split test files only if it improves review.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/openai/server.go`
- [ ] `apps/edge/internal/openai/routes.go`
- [ ] `apps/edge/internal/openai/chat_handler.go`
- [ ] `apps/edge/internal/openai/run_result.go`
- [ ] `apps/edge/internal/openai/stream.go`
- [ ] `apps/edge/internal/openai/strict_output.go`
- [ ] `apps/edge/internal/openai/ollama_passthrough.go`
- [ ] `apps/edge/internal/openai/types.go`
- [ ] `apps/edge/internal/openai/*_test.go`
#### 테스트 작성
- Existing tests should continue to cover behavior. Add route/lifecycle-focused tests only if extracting `routes()` changes route registration in a way direct handler tests cannot catch.
#### 중간 검증
```bash
go test ./apps/edge/internal/openai
```
Expected: OpenAI package tests pass.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/server.go` | REFACTOR-1 |
| `apps/edge/internal/openai/routes.go` | REFACTOR-1 |
| `apps/edge/internal/openai/chat_handler.go` | REFACTOR-1 |
| `apps/edge/internal/openai/run_result.go` | REFACTOR-1 |
| `apps/edge/internal/openai/stream.go` | REFACTOR-1 |
| `apps/edge/internal/openai/strict_output.go` | REFACTOR-1 |
| `apps/edge/internal/openai/ollama_passthrough.go` | REFACTOR-1 |
| `apps/edge/internal/openai/types.go` | REFACTOR-1 |
| `apps/edge/internal/openai/*_test.go` | REFACTOR-1 |
## 최종 검증
```bash
go test ./apps/edge/...
```
Expected: all edge package tests pass. Go test cache output is acceptable for unchanged packages; use `-count=1` if strict output or SSE tests appear stale.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,9 @@ func TestRootCmdIncludesOperationalCommands(t *testing.T) {
"env": false,
"setup": false,
"bootstrap": false,
"node": false,
"nodes": false,
"smoke": false,
}
for _, c := range root.Commands() {
if _, ok := want[c.Name()]; ok {
@ -339,65 +342,6 @@ nodes:
}
}
func TestEnvCmdMarksAutoAdvertiseHostAndOfflineStateForEmptyConfig(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
writeMinimalEdgeYAML(t, cfgPath)
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"env", "--config", cfgPath})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v\n%s", err, out.String())
}
got := out.String()
if !strings.Contains(got, "advertise_host: ") || !strings.Contains(got, " (auto)") {
t.Errorf("expected advertise_host with (auto) source, got:\n%s", got)
}
expectedArtifact := "artifact_base_url: " + defaultArtifactBaseURL(resolveAdvertiseHost("").Host) + " (derived)"
if !strings.Contains(got, expectedArtifact) {
t.Errorf("expected derived artifact_base_url %q, got:\n%s", expectedArtifact, got)
}
expectedBootstrap := "node_bootstrap: " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host))
if !strings.Contains(got, expectedBootstrap) {
t.Errorf("expected node_bootstrap %q, got:\n%s", expectedBootstrap, got)
}
if !strings.Contains(got, "openai_url: (disabled)") {
t.Errorf("expected openai_url (disabled), got:\n%s", got)
}
if !strings.Contains(got, "configured_nodes: 0 (none)") {
t.Errorf("expected configured_nodes 0 (none), got:\n%s", got)
}
if !strings.Contains(got, "connected_nodes: offline") {
t.Errorf("expected connected_nodes offline placeholder, got:\n%s", got)
}
}
func TestResolveEdgeLogPathFallsBackToBinaryDirLogsEdgeLog(t *testing.T) {
got, err := resolveEdgeLogPath("")
if err != nil {
t.Fatalf("resolveEdgeLogPath: %v", err)
}
want := filepath.Join(binaryDir(), "logs", "edge.log")
if got != want {
t.Fatalf("resolveEdgeLogPath empty: got %q, want %q", got, want)
}
}
func TestResolveEdgeLogPathRespectsExplicitPath(t *testing.T) {
explicit := filepath.Join(t.TempDir(), "custom.log")
got, err := resolveEdgeLogPath(explicit)
if err != nil {
t.Fatalf("resolveEdgeLogPath: %v", err)
}
if got != explicit {
t.Fatalf("resolveEdgeLogPath explicit: got %q, want %q", got, explicit)
}
}
func TestBootstrapPackCreatesNodeArtifacts(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
@ -528,32 +472,6 @@ nodes: []
}
}
func TestResolveAdvertiseHostPrefersExplicit(t *testing.T) {
got := resolveAdvertiseHost("explicit.example.test")
if got.Host != "explicit.example.test" || got.Source != "explicit" {
t.Fatalf("explicit advertise: got %+v", got)
}
}
func TestResolveAdvertiseHostFallsBackToAuto(t *testing.T) {
got := resolveAdvertiseHost("")
if got.Host == "" {
t.Fatal("auto advertise host empty")
}
if got.Source != "auto" {
t.Fatalf("expected auto source, got %q", got.Source)
}
}
func TestAddressForCombinesAdvertiseHostWithListenPort(t *testing.T) {
if got := addressFor("10.0.0.1", "0.0.0.0:9090"); got != "10.0.0.1:9090" {
t.Errorf("addressFor: got %q", got)
}
if got := addressFor("host", ""); got != "" {
t.Errorf("addressFor empty listen: got %q", got)
}
}
func TestConfigCheckRejectsDuplicateNodeTokenAndInvalidConfigs(t *testing.T) {
// Duplicate token test
dir := t.TempDir()
@ -669,89 +587,6 @@ nodes:
}
}
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()
// Check one-line bootstrap output
expectedCmd := "curl -fsSL " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host)) + " | bash -s mocked-token-1234"
if strings.TrimSpace(output) != expectedCmd {
t.Errorf("expected only 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()
expectedCmd2 := "curl -fsSL " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host)) + " | bash -s explicit-token-abc"
if strings.TrimSpace(output) != expectedCmd2 {
t.Errorf("expected only 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")
@ -1334,41 +1169,3 @@ func TestSmokeOpenAICommandFailure(t *testing.T) {
t.Errorf("unexpected error message: %v", err3)
}
}
func TestPatchYAMLPreservesArtifactBootstrapField(t *testing.T) {
origYAML := `server:
listen: "0.0.0.0:9090"
bootstrap:
artifact_base_url: "http://old.example.com/artifacts"
`
cfg := &config.EdgeConfig{
Bootstrap: config.EdgeBootstrapConf{
ArtifactBaseURL: "http://example.com/artifacts",
},
}
patched, err := patchYAML([]byte(origYAML), cfg)
if err != nil {
t.Fatalf("patchYAML: %v", err)
}
var doc struct {
Server struct {
Listen string `yaml:"listen"`
} `yaml:"server"`
Bootstrap struct {
ArtifactBaseURL string `yaml:"artifact_base_url"`
} `yaml:"bootstrap"`
}
if err := yaml.Unmarshal(patched, &doc); err != nil {
t.Fatalf("unmarshal patched: %v", err)
}
if doc.Server.Listen != "0.0.0.0:9090" {
t.Errorf("server.listen was modified: %q", doc.Server.Listen)
}
if doc.Bootstrap.ArtifactBaseURL != "http://example.com/artifacts" {
t.Errorf("artifact_base_url = %q", doc.Bootstrap.ArtifactBaseURL)
}
}

View file

@ -0,0 +1,341 @@
package edgecmd
import (
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/spf13/cobra"
"iop/packages/go/config"
"iop/packages/go/version"
)
func bootstrapCmd() *cobra.Command {
c := &cobra.Command{
Use: "bootstrap",
Short: "Prepare node bootstrap artifacts",
Long: `Prepare node bootstrap artifacts served by iop-edge serve.
The default output directory is the configured bootstrap.artifact_dir resolved
beside the iop-edge binary. The directory is static and can be copied as-is.`,
}
c.AddCommand(bootstrapPackCmd())
return c
}
func bootstrapPackCmd() *cobra.Command {
var nodeBinary string
var outputDir string
var target string
var artifactBaseURL string
var edgeAddr string
c := &cobra.Command{
Use: "pack",
Short: "Pack iop-node into the configured bootstrap artifact directory",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := loadEdgeConfigForBootstrapPack(cmd)
if err != nil {
return err
}
adv := resolveAdvertiseHost(cfg.Server.AdvertiseHost)
if nodeBinary == "" {
nodeBinary = filepath.Join(binaryDir(), "iop-node")
}
if outputDir == "" {
outputDir = ResolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir)
}
if target == "" {
target = runtime.GOOS + "-" + runtime.GOARCH
}
if artifactBaseURL == "" {
if cfg.Bootstrap.ArtifactBaseURL != "" {
artifactBaseURL = strings.TrimSuffix(cfg.Bootstrap.ArtifactBaseURL, "/")
} else {
artifactBaseURL = defaultArtifactBaseURL(adv.Host, cfg.Bootstrap.Listen)
}
}
if edgeAddr == "" {
edgeAddr = addressFor(adv.Host, cfg.Server.Listen)
}
if err := packNodeBootstrap(nodeBootstrapPackOptions{
NodeBinary: nodeBinary,
OutputDir: outputDir,
Target: target,
Version: version.Version,
ArtifactBaseURL: artifactBaseURL,
EdgeAddr: edgeAddr,
}); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "wrote bootstrap artifacts to %s\n", outputDir)
fmt.Fprintf(cmd.OutOrStdout(), "bootstrap: %s\n", universalBootstrapScriptURL(artifactBaseURL))
return nil
},
}
c.Flags().StringVar(&nodeBinary, "node-binary", "", "iop-node binary to pack (defaults to iop-node beside iop-edge)")
c.Flags().StringVarP(&outputDir, "output", "o", "", "artifact output directory (defaults to bootstrap.artifact_dir)")
c.Flags().StringVar(&target, "target", "", "target platform label (defaults to current GOOS-GOARCH)")
c.Flags().StringVar(&artifactBaseURL, "artifact-base-url", "", "artifact base URL baked into the bootstrap script")
c.Flags().StringVar(&edgeAddr, "edge-addr", "", "edge transport address baked into node.yaml")
return c
}
func loadEdgeConfigForBootstrapPack(cmd *cobra.Command) (*config.EdgeConfig, error) {
cfg, _, err := LoadEdgeConfig(cmd)
if err == nil {
return cfg, nil
}
if !strings.Contains(err.Error(), "no edge.yaml found") {
return nil, err
}
return &config.EdgeConfig{
Server: config.EdgeServerConf{
Listen: "0.0.0.0:9090",
},
Bootstrap: config.EdgeBootstrapConf{
Listen: "0.0.0.0:18080",
ArtifactDir: "artifacts",
},
}, nil
}
type nodeBootstrapPackOptions struct {
NodeBinary string
OutputDir string
Target string
Version string
ArtifactBaseURL string
EdgeAddr string
}
func packNodeBootstrap(opts nodeBootstrapPackOptions) error {
if opts.NodeBinary == "" {
return errors.New("node binary path is required")
}
if opts.OutputDir == "" {
return errors.New("output directory is required")
}
if opts.Target == "" {
return errors.New("target is required")
}
if strings.ContainsAny(opts.Target, `/\`) {
return fmt.Errorf("target %q must not contain path separators", opts.Target)
}
targetDir := filepath.Join(opts.OutputDir, opts.Target)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("mkdir target dir: %w", err)
}
dstNode := filepath.Join(targetDir, "iop-node")
same, err := sameFile(opts.NodeBinary, dstNode)
if err != nil {
return fmt.Errorf("stat iop-node: %w", err)
}
if same {
if err := os.Chmod(dstNode, 0o755); err != nil {
return fmt.Errorf("chmod iop-node: %w", err)
}
} else {
if err := copyFile(opts.NodeBinary, dstNode, 0o755); err != nil {
return fmt.Errorf("copy iop-node: %w", err)
}
}
sum, err := fileSHA256(dstNode)
if err != nil {
return err
}
sumLine := fmt.Sprintf("%x iop-node\n", sum)
if err := os.WriteFile(filepath.Join(targetDir, "iop-node.sha256"), []byte(sumLine), 0o644); err != nil {
return fmt.Errorf("write iop-node.sha256: %w", err)
}
if err := os.WriteFile(filepath.Join(targetDir, "SHA256SUMS"), []byte(sumLine), 0o644); err != nil {
return fmt.Errorf("write SHA256SUMS: %w", err)
}
bootstrapDir := filepath.Join(opts.OutputDir, "bootstrap")
if err := os.MkdirAll(bootstrapDir, 0o755); err != nil {
return fmt.Errorf("mkdir bootstrap dir: %w", err)
}
script := nodeBootstrapScript(opts)
scriptPath := filepath.Join(bootstrapDir, "node-"+opts.Target+".sh")
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
return fmt.Errorf("write bootstrap script: %w", err)
}
if err := writeUniversalBootstrapScript(bootstrapDir, opts); err != nil {
return err
}
return nil
}
func writeUniversalBootstrapScript(bootstrapDir string, opts nodeBootstrapPackOptions) error {
universalOpts := opts
universalOpts.Target = ""
scriptPath := filepath.Join(bootstrapDir, "node.sh")
if err := os.MkdirAll(bootstrapDir, 0o755); err != nil {
return fmt.Errorf("mkdir bootstrap dir: %w", err)
}
if err := os.WriteFile(scriptPath, []byte(nodeBootstrapScript(universalOpts)), 0o755); err != nil {
return fmt.Errorf("write universal bootstrap script: %w", err)
}
return nil
}
func sameFile(a, b string) (bool, error) {
ai, err := os.Stat(a)
if err != nil {
return false, err
}
bi, err := os.Stat(b)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, err
}
return os.SameFile(ai, bi), nil
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
return os.Chmod(dst, mode)
}
func fileSHA256(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open checksum file: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return nil, fmt.Errorf("hash file: %w", err)
}
return h.Sum(nil), nil
}
func shellQuote(s string) string {
return strconv.Quote(s)
}
func nodeBootstrapScript(opts nodeBootstrapPackOptions) string {
return `#!/usr/bin/env bash
set -euo pipefail
VERSION=` + shellQuote(opts.Version) + `
DEFAULT_TARGET=` + shellQuote(opts.Target) + `
DEFAULT_ARTIFACT_BASE_URL=` + shellQuote(strings.TrimSuffix(opts.ArtifactBaseURL, "/")) + `
DEFAULT_EDGE_ADDR=` + shellQuote(opts.EdgeAddr) + `
POSITIONAL_NODE_TOKEN="${1:-}"
TARGET="$DEFAULT_TARGET"
ARTIFACT_BASE_URL="$DEFAULT_ARTIFACT_BASE_URL"
EDGE_ADDR="$DEFAULT_EDGE_ADDR"
NODE_TOKEN="$POSITIONAL_NODE_TOKEN"
INSTALL_DIR="${IOP_HOME:-$HOME/iop-field}"
CONFIG_FILE="${IOP_NODE_CONFIG:-$INSTALL_DIR/node.yaml}"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iop-node-bootstrap.XXXXXX")"
detect_target() {
local os arch
case "$(uname -s)" in
Linux*) os="linux" ;;
Darwin*) os="darwin" ;;
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
*)
echo "[iop-bootstrap] unsupported OS: $(uname -s)" >&2
return 2
;;
esac
case "$(uname -m)" in
x86_64|amd64) arch="amd64" ;;
arm64|aarch64) arch="arm64" ;;
*)
echo "[iop-bootstrap] unsupported architecture: $(uname -m)" >&2
return 2
;;
esac
printf '%s-%s\n' "$os" "$arch"
}
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
ARTIFACT_BASE_URL="${ARTIFACT_BASE_URL%/}"
if [[ -z "$NODE_TOKEN" ]]; then
echo "[iop-bootstrap] missing node token" >&2
echo "[iop-bootstrap] usage: curl -fsSL <artifact-base-url>/bootstrap/node.sh | bash -s TOKEN" >&2
exit 2
fi
if [[ -z "$TARGET" ]]; then
TARGET="$(detect_target)"
fi
BIN_FILE="$INSTALL_DIR/iop-node"
if [[ "$TARGET" == windows-* ]]; then
BIN_FILE="$INSTALL_DIR/iop-node.exe"
fi
echo "[iop-bootstrap] version=$VERSION target=$TARGET"
echo "[iop-bootstrap] install_dir=$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
curl -fsSL "$ARTIFACT_BASE_URL/$TARGET/iop-node" -o "$TMP_DIR/iop-node"
curl -fsSL "$ARTIFACT_BASE_URL/$TARGET/SHA256SUMS" -o "$TMP_DIR/SHA256SUMS"
if command -v sha256sum >/dev/null 2>&1; then
(cd "$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | sha256sum -c -)
elif command -v shasum >/dev/null 2>&1; then
(cd "$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | shasum -a 256 -c -)
else
echo "[iop-bootstrap] warning: no sha256 checker found; skipping checksum verification" >&2
fi
cp "$TMP_DIR/iop-node" "$BIN_FILE"
chmod +x "$BIN_FILE"
if command -v xattr >/dev/null 2>&1; then
xattr -d com.apple.quarantine "$BIN_FILE" 2>/dev/null || true
fi
cat > "$CONFIG_FILE" <<YAML
transport:
edge_addr: "$EDGE_ADDR"
token: "$NODE_TOKEN"
logging:
level: "${IOP_LOG_LEVEL:-info}"
pretty: true
metrics:
port: ${IOP_NODE_METRICS_PORT:-9091}
YAML
echo "[iop-bootstrap] config=$CONFIG_FILE"
echo "[iop-bootstrap] starting node against $EDGE_ADDR"
exec "$BIN_FILE" serve --config "$CONFIG_FILE"
`
}

View file

@ -0,0 +1,154 @@
package edgecmd
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"iop/apps/edge/internal/node"
"iop/packages/go/config"
"iop/packages/go/hostsetup"
)
func LoadEdgeConfig(cmd *cobra.Command) (*config.EdgeConfig, string, error) {
path, err := ResolveConfigPath(cmd)
if err != nil {
return nil, "", err
}
cfg, err := config.LoadEdge(path)
if err != nil {
return nil, "", fmt.Errorf("load config: %w", err)
}
return cfg, path, nil
}
func configCmd() *cobra.Command {
c := &cobra.Command{
Use: "config",
Short: "Create, inspect, and validate edge config",
Long: `Create, inspect, and validate the bundle-local edge.yaml configuration.
Typical local flow:
1. iop-edge config init
2. iop-edge env
3. iop-edge node register <node-id>
4. iop-edge config check
5. iop-edge serve`,
Example: ` iop-edge config init
iop-edge env
iop-edge node register node-silicon-ollama --adapter ollama
iop-edge config check`,
}
c.AddCommand(configInitCmd(), configPrintCmd(), configCheckCmd())
return c
}
func configInitCmd() *cobra.Command {
output := "edge.yaml"
var force bool
var stdout bool
c := &cobra.Command{
Use: "init",
Short: "Write an edge.yaml template",
Long: "Write a deployable edge.yaml template to the current bundle directory or stdout.\n" +
"Run this beside a local iop-edge binary, then run `iop-edge config check`.",
Example: " iop-edge config init\n" +
" iop-edge config init --stdout\n" +
" iop-edge config init --output edge.yaml\n" +
" iop-edge config check",
RunE: func(cmd *cobra.Command, _ []string) error {
template := []byte(hostsetup.EdgeBundleConfigTemplate())
if stdout || output == "-" {
_, err := cmd.OutOrStdout().Write(template)
return err
}
if !force {
if _, err := os.Stat(output); err == nil {
return fmt.Errorf("%s already exists (use --force to overwrite)", output)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("stat output: %w", err)
}
}
if dir := filepath.Dir(output); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
}
if err := os.WriteFile(output, template, 0o600); err != nil {
return fmt.Errorf("write %s: %w", output, err)
}
fmt.Fprintf(cmd.OutOrStdout(), "wrote %s\n", output)
return nil
},
}
c.Flags().StringVarP(&output, "output", "o", output, "output path")
c.Flags().BoolVar(&stdout, "stdout", false, "print template to stdout instead of writing a file")
c.Flags().BoolVar(&force, "force", false, "overwrite an existing output file")
return c
}
func configPrintCmd() *cobra.Command {
return &cobra.Command{
Use: "print",
Short: "Print effective edge config as YAML",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, _, err := LoadEdgeConfig(cmd)
if err != nil {
return err
}
enc := yaml.NewEncoder(cmd.OutOrStdout())
defer enc.Close()
return enc.Encode(cfg)
},
}
}
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)
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
},
}
}
func validateEdgeConfig(cfg *config.EdgeConfig) error {
_, err := node.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)
}
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
}

View file

@ -0,0 +1,218 @@
package edgecmd
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
"iop/packages/go/config"
)
const minimalEdgeYAML = "server:\n listen: \"0.0.0.0:9090\"\n"
func writeMinimalEdgeYAML(t *testing.T, path string) {
t.Helper()
if err := os.WriteFile(path, []byte(minimalEdgeYAML), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestEnvCmdMarksAutoAdvertiseHostAndOfflineStateForEmptyConfig(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{"env", "--config", cfgPath})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v\n%s", err, out.String())
}
got := out.String()
if !strings.Contains(got, "advertise_host: ") || !strings.Contains(got, " (auto)") {
t.Errorf("expected advertise_host with (auto) source, got:\n%s", got)
}
expectedArtifact := "artifact_base_url: " + defaultArtifactBaseURL(resolveAdvertiseHost("").Host) + " (derived)"
if !strings.Contains(got, expectedArtifact) {
t.Errorf("expected derived artifact_base_url %q, got:\n%s", expectedArtifact, got)
}
expectedBootstrap := "node_bootstrap: " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host))
if !strings.Contains(got, expectedBootstrap) {
t.Errorf("expected node_bootstrap %q, got:\n%s", expectedBootstrap, got)
}
if !strings.Contains(got, "openai_url: (disabled)") {
t.Errorf("expected openai_url (disabled), got:\n%s", got)
}
if !strings.Contains(got, "configured_nodes: 0 (none)") {
t.Errorf("expected configured_nodes 0 (none), got:\n%s", got)
}
if !strings.Contains(got, "connected_nodes: offline") {
t.Errorf("expected connected_nodes offline placeholder, got:\n%s", got)
}
}
func TestResolveEdgeLogPathFallsBackToBinaryDirLogsEdgeLog(t *testing.T) {
got, err := ResolveEdgeLogPath("")
if err != nil {
t.Fatalf("ResolveEdgeLogPath: %v", err)
}
want := filepath.Join(binaryDir(), "logs", "edge.log")
if got != want {
t.Fatalf("ResolveEdgeLogPath empty: got %q, want %q", got, want)
}
}
func TestResolveEdgeLogPathRespectsExplicitPath(t *testing.T) {
explicit := filepath.Join(t.TempDir(), "custom.log")
got, err := ResolveEdgeLogPath(explicit)
if err != nil {
t.Fatalf("ResolveEdgeLogPath: %v", err)
}
if got != explicit {
t.Fatalf("ResolveEdgeLogPath explicit: got %q, want %q", got, explicit)
}
}
func TestResolveAdvertiseHostPrefersExplicit(t *testing.T) {
got := resolveAdvertiseHost("explicit.example.test")
if got.Host != "explicit.example.test" || got.Source != "explicit" {
t.Fatalf("explicit advertise: got %+v", got)
}
}
func TestResolveAdvertiseHostFallsBackToAuto(t *testing.T) {
got := resolveAdvertiseHost("")
if got.Host == "" {
t.Fatal("auto advertise host empty")
}
if got.Source != "auto" {
t.Fatalf("expected auto source, got %q", got.Source)
}
}
func TestAddressForCombinesAdvertiseHostWithListenPort(t *testing.T) {
if got := addressFor("10.0.0.1", "0.0.0.0:9090"); got != "10.0.0.1:9090" {
t.Errorf("addressFor: got %q", got)
}
if got := addressFor("host", ""); got != "" {
t.Errorf("addressFor empty listen: got %q", got)
}
}
func TestNodeRegisterUpsertsYAMLAndOutputsBootstrap(t *testing.T) {
origGen := tokenGenerator
defer func() { tokenGenerator = origGen }()
tokenGenerator = func() string {
return "mocked-token-1234"
}
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, "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()
expectedCmd := "curl -fsSL " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host)) + " | bash -s mocked-token-1234"
if strings.TrimSpace(output) != expectedCmd {
t.Errorf("expected only bootstrap command %q, got %q", expectedCmd, output)
}
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)
}
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])
}
root = NewRoot(Options{})
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()
expectedCmd2 := "curl -fsSL " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host)) + " | bash -s explicit-token-abc"
if strings.TrimSpace(output) != expectedCmd2 {
t.Errorf("expected only bootstrap command %q, got %q", expectedCmd2, output)
}
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)
}
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 TestPatchYAMLPreservesArtifactBootstrapField(t *testing.T) {
origYAML := `server:
listen: "0.0.0.0:9090"
bootstrap:
artifact_base_url: "http://old.example.com/artifacts"
`
cfg := &config.EdgeConfig{
Bootstrap: config.EdgeBootstrapConf{
ArtifactBaseURL: "http://example.com/artifacts",
},
}
patched, err := patchYAML([]byte(origYAML), cfg)
if err != nil {
t.Fatalf("patchYAML: %v", err)
}
var doc struct {
Server struct {
Listen string `yaml:"listen"`
} `yaml:"server"`
Bootstrap struct {
ArtifactBaseURL string `yaml:"artifact_base_url"`
} `yaml:"bootstrap"`
}
if err := yaml.Unmarshal(patched, &doc); err != nil {
t.Fatalf("unmarshal patched: %v", err)
}
if doc.Server.Listen != "0.0.0.0:9090" {
t.Errorf("server.listen was modified: %q", doc.Server.Listen)
}
if doc.Bootstrap.ArtifactBaseURL != "http://example.com/artifacts" {
t.Errorf("artifact_base_url = %q", doc.Bootstrap.ArtifactBaseURL)
}
}

View file

@ -0,0 +1,178 @@
package edgecmd
import (
"fmt"
"io"
"net"
"os"
"sort"
"strings"
"github.com/spf13/cobra"
"iop/packages/go/config"
)
// envCmd prints the resolved environment for the bundled edge binary so the
// user can verify config discovery, advertise host, log path, transport
// address, artifact/bootstrap URL, and OpenAI-compatible URL without starting
// edge. It does not bind any port or connect to nodes.
func envCmd() *cobra.Command {
return &cobra.Command{
Use: "env",
Short: "Print resolved edge environment without starting edge",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, path, err := LoadEdgeConfig(cmd)
if err != nil {
return err
}
logPath, err := ResolveEdgeLogPath(cfg.Logging.Path)
if err != nil {
return err
}
cfg.Bootstrap.ArtifactDir = ResolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir)
renderEnv(cmd.OutOrStdout(), envReport{
ConfigPath: path,
BinaryDir: binaryDir(),
LogPath: logPath,
Advertise: resolveAdvertiseHost(cfg.Server.AdvertiseHost),
Server: cfg.Server,
Bootstrap: cfg.Bootstrap,
OpenAI: cfg.OpenAI,
A2A: cfg.A2A,
Nodes: cfg.Nodes,
})
return nil
},
}
}
type advertise struct {
Host string
Source string
}
func resolveAdvertiseHost(explicit string) advertise {
if explicit != "" {
return advertise{Host: explicit, Source: "explicit"}
}
if ip := nonLoopbackIPv4(); ip != "" {
return advertise{Host: ip, Source: "auto"}
}
if hn, err := os.Hostname(); err == nil && hn != "" {
return advertise{Host: hn, Source: "auto"}
}
return advertise{Host: "127.0.0.1", Source: "auto"}
}
func nonLoopbackIPv4() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok || ipnet.IP.IsLoopback() {
continue
}
ip4 := ipnet.IP.To4()
if ip4 == nil {
continue
}
return ip4.String()
}
return ""
}
// addressFor combines an advertise host with the port from a listen string
// (e.g. "0.0.0.0:9090" -> "<host>:9090"). If the listen string is invalid it
// is returned unchanged.
func addressFor(host, listen string) string {
_, port, err := net.SplitHostPort(listen)
if err != nil || port == "" {
return listen
}
return net.JoinHostPort(host, port)
}
func defaultArtifactBaseURL(host string, listen ...string) string {
bootstrapListen := "0.0.0.0:18080"
if len(listen) > 0 && listen[0] != "" {
bootstrapListen = listen[0]
}
return "http://" + addressFor(host, bootstrapListen)
}
func bootstrapScriptURL(baseURL, target string) string {
return strings.TrimSuffix(baseURL, "/") + "/bootstrap/node-" + target + ".sh"
}
func universalBootstrapScriptURL(baseURL string) string {
return strings.TrimSuffix(baseURL, "/") + "/bootstrap/node.sh"
}
type envReport struct {
ConfigPath string
BinaryDir string
LogPath string
Advertise advertise
Server config.EdgeServerConf
Bootstrap config.EdgeBootstrapConf
OpenAI config.EdgeOpenAIConf
A2A config.EdgeA2AConf
Nodes []config.NodeDefinition
}
func renderEnv(w io.Writer, r envReport) {
artifactURL := strings.TrimSuffix(r.Bootstrap.ArtifactBaseURL, "/")
artifactSource := "configured"
if artifactURL == "" {
artifactURL = defaultArtifactBaseURL(r.Advertise.Host, r.Bootstrap.Listen)
artifactSource = "derived"
}
fmt.Fprintf(w, "config: %s\n", r.ConfigPath)
fmt.Fprintf(w, "binary_dir: %s\n", r.BinaryDir)
fmt.Fprintf(w, "log_path: %s\n", r.LogPath)
fmt.Fprintf(w, "advertise_host: %s (%s)\n", r.Advertise.Host, r.Advertise.Source)
fmt.Fprintf(w, "node_transport: %s\n", addressFor(r.Advertise.Host, r.Server.Listen))
fmt.Fprintf(w, "artifact_dir: %s\n", r.Bootstrap.ArtifactDir)
fmt.Fprintf(w, "artifact_listen: %s\n", r.Bootstrap.Listen)
fmt.Fprintf(w, "artifact_base_url: %s (%s)\n", artifactURL, artifactSource)
fmt.Fprintf(w, "node_bootstrap: %s\n", universalBootstrapScriptURL(artifactURL))
if r.OpenAI.Enabled {
fmt.Fprintf(w, "openai_url: http://%s/v1\n", addressFor(r.Advertise.Host, r.OpenAI.Listen))
} else {
fmt.Fprintln(w, "openai_url: (disabled)")
}
if r.A2A.Enabled {
path := r.A2A.Path
if path == "" {
path = "/"
}
fmt.Fprintf(w, "a2a_url: http://%s%s\n", addressFor(r.Advertise.Host, r.A2A.Listen), path)
} else {
fmt.Fprintln(w, "a2a_url: (disabled)")
}
fmt.Fprintf(w, "configured_nodes: %s\n", summarizeNodes(r.Nodes))
fmt.Fprintln(w, "connected_nodes: offline (env does not start edge)")
}
func summarizeNodes(nodes []config.NodeDefinition) string {
if len(nodes) == 0 {
return "0 (none)"
}
labels := make([]string, 0, len(nodes))
for _, n := range nodes {
label := n.ID
if label == "" {
label = n.Alias
}
if label == "" {
label = "(unnamed)"
}
labels = append(labels, label)
}
sort.Strings(labels)
return fmt.Sprintf("%d (%s)", len(nodes), strings.Join(labels, ", "))
}

View file

@ -0,0 +1,376 @@
package edgecmd
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/google/uuid"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"iop/packages/go/config"
"iop/packages/go/version"
)
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 refreshBootstrapForExistingArtifact(cfg *config.EdgeConfig, target, artifactURL string) error {
outputDir := ResolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir)
adv := resolveAdvertiseHost(cfg.Server.AdvertiseHost)
bootstrapDir := filepath.Join(outputDir, "bootstrap")
baseOpts := nodeBootstrapPackOptions{
OutputDir: outputDir,
Version: version.Version,
ArtifactBaseURL: artifactURL,
EdgeAddr: addressFor(adv.Host, cfg.Server.Listen),
}
if err := writeUniversalBootstrapScript(bootstrapDir, baseOpts); err != nil {
return err
}
nodeBinary := filepath.Join(outputDir, target, "iop-node")
if _, err := os.Stat(nodeBinary); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("stat node artifact: %w", err)
}
targetOpts := baseOpts
targetOpts.NodeBinary = nodeBinary
targetOpts.Target = target
return packNodeBootstrap(targetOpts)
}
func nodeCmd() *cobra.Command {
c := &cobra.Command{
Use: "node",
Short: "Manage edge nodes",
Long: `Manage node records stored in edge.yaml.
Use node register to create or update the Node definition and print the
one-line bootstrap command for the target host. After registration, run
iop-edge config check and then start iop-edge serve.`,
Example: ` iop-edge node register node-silicon-ollama --adapter ollama
iop-edge config check
iop-edge serve`,
}
c.AddCommand(nodeRegisterCmd())
return c
}
func nodeRegisterCmd() *cobra.Command {
c := &cobra.Command{
Use: "register [node-id]",
Short: "Register or update a node record",
Long: `Register or update a node record in edge.yaml and print a one-line bootstrap command.
The command writes the node id, alias, token, adapter, and runtime settings into
edge.yaml. The printed bootstrap command is the user-facing command to run on
the Node host; the user should not create node.yaml or run iop-node serve
directly in the default flow.`,
Example: ` iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434
iop-edge node register node-linux-cli --adapter cli
iop-edge config check`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
nodeID := args[0]
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)
}
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 {
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
}
if nodeDef.Token == "" {
nodeDef.Token = tokenGenerator()
}
} else {
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]
}
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 = defaultArtifactBaseURL(resolveAdvertiseHost(cfg.Server.AdvertiseHost).Host, cfg.Bootstrap.Listen)
}
if err := validateEdgeConfig(&cfg); err != nil {
return fmt.Errorf("validate node register: %w", err)
}
if err := refreshBootstrapForExistingArtifact(&cfg, target, artifactURL); err != nil {
return fmt.Errorf("refresh bootstrap artifacts: %w", err)
}
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)
}
bootstrapURL := universalBootstrapScriptURL(artifactURL)
fmt.Fprintf(cmd.OutOrStdout(), "curl -fsSL %s | bash -s %s\n", bootstrapURL, nodeDef.Token)
return nil
},
}
c.Flags().StringVar(&regAlias, "alias", "", "node alias (defaults to node-id)")
c.Flags().StringVar(&regToken, "token", "", "node token (defaults to auto-generated uuid)")
c.Flags().StringVar(&regAdapter, "adapter", "cli", "node adapter: ollama or cli")
c.Flags().StringVar(&regOllamaBaseURL, "ollama-base-url", "", "ollama base URL")
c.Flags().IntVar(&regOllamaContextSize, "ollama-context-size", 2048, "ollama context size")
c.Flags().IntVar(&regRuntimeConcurrency, "runtime-concurrency", 1, "runtime concurrency limit")
c.Flags().StringVar(&regWorkspaceRoot, "workspace-root", "", "workspace root path")
c.Flags().StringVar(&regTarget, "target", "", "bootstrap target platform (e.g. darwin-arm64, linux-amd64)")
c.Flags().StringVar(&regArtifactBaseURL, "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]
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)
}
fieldsToPatch := make(map[string]string)
if cfg.Bootstrap.ArtifactBaseURL != "" {
fieldsToPatch["artifact_base_url"] = cfg.Bootstrap.ArtifactBaseURL
}
patchBootstrapStringFields(rootMap, fieldsToPatch)
out, err := yaml.Marshal(&doc)
if err != nil {
return nil, fmt.Errorf("marshal patched doc: %w", err)
}
return out, nil
}
func patchBootstrapStringFields(rootMap *yaml.Node, fields map[string]string) {
if len(fields) == 0 {
return
}
var bootstrapVal *yaml.Node
foundBootstrap := false
for i := 0; i < len(rootMap.Content); i += 2 {
if rootMap.Content[i].Value == "bootstrap" {
bootstrapVal = rootMap.Content[i+1]
foundBootstrap = true
break
}
}
if !foundBootstrap {
bootstrapKey := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bootstrap"}
bootstrapVal = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
rootMap.Content = append(rootMap.Content, bootstrapKey, bootstrapVal)
}
if bootstrapVal.Kind != yaml.MappingNode {
return
}
keys := []string{"artifact_base_url"}
for _, k := range keys {
v, ok := fields[k]
if !ok || v == "" {
continue
}
foundField := false
for j := 0; j < len(bootstrapVal.Content); j += 2 {
if bootstrapVal.Content[j].Value == k {
bootstrapVal.Content[j+1].Value = v
bootstrapVal.Content[j+1].Tag = "!!str"
foundField = true
break
}
}
if !foundField {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}
valNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: v}
bootstrapVal.Content = append(bootstrapVal.Content, keyNode, valNode)
}
}
}

View file

@ -0,0 +1,55 @@
package edgecmd
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
"iop/packages/go/config"
)
func nodesCmd() *cobra.Command {
c := &cobra.Command{
Use: "nodes",
Short: "Inspect registered edge nodes",
Long: `Inspect and list configured or connected execution nodes.`,
}
c.AddCommand(nodesListCmd())
return c
}
func nodesListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List configured edge nodes and connection status",
Long: `List all nodes registered in the edge configuration file.
When the server is offline or this command is run standalone, all nodes will show their status as 'offline' or 'configured'.`,
Example: ` iop-edge nodes list`,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, path, err := LoadEdgeConfig(cmd)
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Config Source: %s\n\n", path)
if len(cfg.Nodes) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No nodes configured.")
return nil
}
nodes := make([]config.NodeDefinition, len(cfg.Nodes))
copy(nodes, cfg.Nodes)
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].ID < nodes[j].ID
})
fmt.Fprintf(cmd.OutOrStdout(), "%-25s %-20s %-40s %-12s\n", "NODE ID", "ALIAS", "TOKEN", "STATUS")
fmt.Fprintf(cmd.OutOrStdout(), "%s\n", strings.Repeat("-", 100))
for _, n := range nodes {
fmt.Fprintf(cmd.OutOrStdout(), "%-25s %-20s %-40s %-12s\n", n.ID, n.Alias, n.Token, "offline (configured)")
}
return nil
},
}
}

View file

@ -0,0 +1,84 @@
package edgecmd
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
)
// ResolveConfigPath returns the edge config path to load. Candidate order:
// 1. explicit --config flag
// 2. ./edge.yaml in the current working directory
// 3. <binary-dir>/edge.yaml beside the executable
// 4. configs/edge.yaml repo-dev fallback (only if it exists)
//
// setup uses its own /etc/iop/edge.yaml default and does not call this helper.
func ResolveConfigPath(cmd *cobra.Command) (string, error) {
if isConfigExplicit(cmd) {
return configFlagValue(cmd), nil
}
if _, err := os.Stat("edge.yaml"); err == nil {
return "edge.yaml", nil
}
if exe, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exe), "edge.yaml")
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
if _, err := os.Stat("configs/edge.yaml"); err == nil {
return "configs/edge.yaml", nil
}
return "", errors.New("no edge.yaml found: pass --config or run beside a bundle edge.yaml")
}
func isConfigExplicit(cmd *cobra.Command) bool {
flag := cmd.Root().PersistentFlags().Lookup("config")
return flag != nil && flag.Changed
}
func configFlagValue(cmd *cobra.Command) string {
flag := cmd.Root().PersistentFlags().Lookup("config")
if flag == nil {
return ""
}
return flag.Value.String()
}
// ResolveEdgeLogPath returns the effective edge log path. If explicit is set
// it wins. Otherwise it falls back to <binary-dir>/logs/edge.log.
func ResolveEdgeLogPath(explicit string) (string, error) {
if explicit != "" {
return explicit, nil
}
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("resolve binary: %w", err)
}
return filepath.Join(filepath.Dir(exe), "logs", "edge.log"), nil
}
func ResolveBootstrapArtifactDir(dir string) string {
if dir == "" {
dir = "artifacts"
}
if filepath.IsAbs(dir) {
return dir
}
return filepath.Join(binaryDir(), dir)
}
// binaryDir returns the directory of the running executable. Falls back to the
// current working directory if os.Executable fails.
func binaryDir() string {
if exe, err := os.Executable(); err == nil {
return filepath.Dir(exe)
}
if wd, err := os.Getwd(); err == nil {
return wd
}
return "."
}

View file

@ -0,0 +1,55 @@
package edgecmd
import "github.com/spf13/cobra"
// Options provides command roots owned by the edge main package while keeping
// command support logic inside the edgecmd package.
type Options struct {
ConfigPath *string
Serve *cobra.Command
Console *cobra.Command
}
// NewRoot builds the iop-edge command surface.
func NewRoot(opts Options) *cobra.Command {
configPath := opts.ConfigPath
if configPath == nil {
var localConfigPath string
configPath = &localConfigPath
}
root := &cobra.Command{
Use: "edge",
Short: "IOP Edge — execution group controller",
Long: `IOP Edge is the backend execution group controller that coordinates multiple execution nodes.
It manages token-based node registration, node registry, node configuration delivery, and OpenAI-compatible routing.
The official local development and field test flow is as follows:
1. config init - Write a clean local edge.yaml configuration template.
2. env - Verify resolved local environment, log paths, and listen URLs.
3. node register - Add a node record to edge.yaml and generate its one-line bootstrap command.
4. config check - Validate the edge.yaml configuration structure and constraints.
5. serve (or console) - Start the Edge server (or interactive console) to accept connections.
6. nodes list - List configured nodes and show their connection status.
7. smoke openai - Perform an E2E health and chat completion smoke test on the OpenAI endpoint.`,
Example: ` iop-edge config init
iop-edge env
iop-edge node register my-node --adapter cli
iop-edge config check
iop-edge serve
iop-edge nodes list
iop-edge smoke openai --model gemma4:26b`,
}
root.PersistentFlags().StringVarP(configPath, "config", "c", "", "config file path (defaults to bundle-local edge.yaml)")
commands := make([]*cobra.Command, 0, 10)
if opts.Serve != nil {
commands = append(commands, opts.Serve)
}
if opts.Console != nil {
commands = append(commands, opts.Console)
}
commands = append(commands, versionCmd(), configCmd(), envCmd(), setupCmd(), bootstrapCmd(), nodeCmd(), nodesCmd(), smokeCmd())
root.AddCommand(commands...)
return root
}

View file

@ -0,0 +1,34 @@
package edgecmd
import (
"github.com/spf13/cobra"
"iop/packages/go/hostsetup"
)
func setupCmd() *cobra.Command {
var opts hostsetup.SetupOptions
c := &cobra.Command{
Use: "setup",
Short: "Prepare host environment and systemd unit for iop-edge",
RunE: func(cmd *cobra.Command, _ []string) error {
if isConfigExplicit(cmd) {
opts.ConfigPath = configFlagValue(cmd)
} else {
opts.ConfigPath = "/etc/iop/edge.yaml"
}
return hostsetup.Run(cmd.Context(), hostsetup.EdgeSpec(), opts, cmd.OutOrStdout())
},
}
c.Flags().StringVar(&opts.BinaryPath, "binary", "", "path to iop-edge binary (defaults to current executable)")
c.Flags().StringVar(&opts.DataDir, "data-dir", "", "data directory (defaults to spec)")
c.Flags().StringVar(&opts.UnitPath, "unit", "", "systemd unit path (defaults to spec)")
c.Flags().StringVar(&opts.User, "user", "", "service user")
c.Flags().StringVar(&opts.Group, "group", "", "service group")
c.Flags().BoolVar(&opts.Enable, "enable", false, "enable the systemd unit")
c.Flags().BoolVar(&opts.Start, "start", false, "start the systemd unit")
c.Flags().BoolVar(&opts.Restart, "restart", false, "restart the systemd unit")
c.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print plan and previews without making changes")
c.Flags().BoolVar(&opts.OverwriteConfig, "overwrite-config", false, "overwrite an existing config file")
return c
}

View file

@ -0,0 +1,248 @@
package edgecmd
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"github.com/spf13/cobra"
)
func smokeCmd() *cobra.Command {
c := &cobra.Command{
Use: "smoke",
Short: "Run verification and smoke tests",
Long: `Run diagnostic smoke tests to verify the connectivity and functionality of IOP Edge components.
Typical local flow:
1. iop-edge config init
2. iop-edge node register <node-id>
3. iop-edge serve
4. iop-edge smoke openai --model <model>`,
Example: ` iop-edge smoke openai --model gemma4:26b
iop-edge smoke openai --model gemma4:26b --base-url http://127.0.0.1:8080`,
}
c.AddCommand(smokeOpenAICmd())
return c
}
var (
smokeModel string
smokeBaseURL string
smokePrompt string
smokeTimeout string
)
type openAIModel struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
}
type openAIModelsResponse struct {
Object string `json:"object"`
Data []openAIModel `json:"data"`
}
type chatCompletionMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatCompletionChoice struct {
Index int `json:"index"`
Message chatCompletionMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type chatCompletionUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type chatCompletionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []chatCompletionChoice `json:"choices"`
Usage *chatCompletionUsage `json:"usage"`
}
func resolveSmokeBaseURL(cmd *cobra.Command) (string, error) {
if smokeBaseURL != "" {
return strings.TrimSuffix(smokeBaseURL, "/"), nil
}
cfg, _, err := LoadEdgeConfig(cmd)
if err != nil {
return "", fmt.Errorf("failed to load edge config to resolve base URL (pass --base-url to override): %w", err)
}
listen := cfg.OpenAI.Listen
if listen == "" {
listen = "127.0.0.1:8080"
}
host, port, err := net.SplitHostPort(listen)
if err != nil {
return "http://127.0.0.1:8080", nil
}
if host == "0.0.0.0" || host == "" {
host = "127.0.0.1"
}
return "http://" + net.JoinHostPort(host, port), nil
}
func smokeOpenAICmd() *cobra.Command {
c := &cobra.Command{
Use: "openai",
Short: "Perform OpenAI-compatible endpoint smoke test",
Long: `Validate the IOP Edge OpenAI-compatible HTTP API surface.
It runs a three-step diagnostics check:
1. Checks GET /healthz (server status).
2. Checks GET /v1/models (advertised model compatibility).
3. Checks POST /v1/chat/completions (inference request processing).
If --base-url is not provided, it auto-discovers the OpenAI URL from the effective config's listen address.`,
Example: ` iop-edge smoke openai --model gemma4:26b
iop-edge smoke openai --model gemma4:26b --base-url http://127.0.0.1:8080 --prompt "say hello" --timeout 15s`,
RunE: func(cmd *cobra.Command, _ []string) error {
baseURL, err := resolveSmokeBaseURL(cmd)
if err != nil {
return err
}
timeoutDur := 10 * time.Second
if smokeTimeout != "" {
d, err := time.ParseDuration(smokeTimeout)
if err != nil {
return fmt.Errorf("invalid timeout duration: %w", err)
}
timeoutDur = d
}
client := &http.Client{
Timeout: timeoutDur,
}
fmt.Fprintf(cmd.OutOrStdout(), "IOP Edge OpenAI Smoke Test\n")
fmt.Fprintf(cmd.OutOrStdout(), "========================================\n")
fmt.Fprintf(cmd.OutOrStdout(), "Target Endpoint: %s\n", baseURL)
fmt.Fprintf(cmd.OutOrStdout(), "Target Model: %s\n", smokeModel)
fmt.Fprintf(cmd.OutOrStdout(), "Timeout: %s\n\n", timeoutDur)
fmt.Fprintf(cmd.OutOrStdout(), "Step 1: Checking /healthz ... ")
healthURL := baseURL + "/healthz"
resp, err := client.Get(healthURL)
if err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("GET %s failed: %w (make sure the edge server is running)", healthURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("GET %s returned non-200 status %d: %s", healthURL, resp.StatusCode, string(body))
}
var healthData map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&healthData); err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("failed to parse /healthz JSON response: %w", err)
}
fmt.Fprintln(cmd.OutOrStdout(), "[OK]")
fmt.Fprintf(cmd.OutOrStdout(), "Step 2: Checking /v1/models ... ")
modelsURL := baseURL + "/v1/models"
resp, err = client.Get(modelsURL)
if err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("GET %s failed: %w", modelsURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("GET %s returned non-200 status %d: %s", modelsURL, resp.StatusCode, string(body))
}
var modelsResp openAIModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&modelsResp); err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("failed to parse /v1/models JSON response: %w", err)
}
modelIDs := make([]string, 0, len(modelsResp.Data))
modelMatched := false
for _, m := range modelsResp.Data {
modelIDs = append(modelIDs, m.ID)
if m.ID == smokeModel {
modelMatched = true
}
}
fmt.Fprintf(cmd.OutOrStdout(), "[OK] (found models: %s)\n", strings.Join(modelIDs, ", "))
if !modelMatched {
fmt.Fprintf(cmd.OutOrStdout(), "WARNING: Target model %q is not advertised by /v1/models (advertised: %s)\n", smokeModel, strings.Join(modelIDs, ", "))
}
fmt.Fprintf(cmd.OutOrStdout(), "Step 3: Checking /v1/chat/completions ... ")
prompt := smokePrompt
if prompt == "" {
prompt = "ping"
}
reqPayload := map[string]interface{}{
"model": smokeModel,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
"stream": false,
}
payloadBytes, err := json.Marshal(reqPayload)
if err != nil {
return fmt.Errorf("failed to marshal request payload: %w", err)
}
chatURL := baseURL + "/v1/chat/completions"
resp, err = client.Post(chatURL, "application/json", bytes.NewReader(payloadBytes))
if err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("POST %s failed: %w (make sure active nodes are online)", chatURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("POST %s returned non-200 status %d: %s", chatURL, resp.StatusCode, string(body))
}
var chatResp chatCompletionResponse
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("failed to parse /v1/chat/completions JSON response: %w", err)
}
if len(chatResp.Choices) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("POST %s returned empty choices", chatURL)
}
content := chatResp.Choices[0].Message.Content
if strings.TrimSpace(content) == "" {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("POST %s returned empty assistant message content", chatURL)
}
fmt.Fprintf(cmd.OutOrStdout(), "[OK]\n")
fmt.Fprintf(cmd.OutOrStdout(), "Response Content: %q\n\n", content)
fmt.Fprintf(cmd.OutOrStdout(), "IOP Edge OpenAI Smoke Test SUCCESS!\n")
return nil
},
}
c.Flags().StringVar(&smokeModel, "model", "", "model identifier to check (required)")
c.Flags().StringVar(&smokeBaseURL, "base-url", "", "override OpenAI base URL (e.g. http://127.0.0.1:8080)")
c.Flags().StringVar(&smokePrompt, "prompt", "ping", "prompt text to send")
c.Flags().StringVar(&smokeTimeout, "timeout", "10s", "client timeout duration")
c.MarkFlagRequired("model")
return c
}

View file

@ -0,0 +1,20 @@
package edgecmd
import (
"fmt"
"github.com/spf13/cobra"
"iop/packages/go/version"
)
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print edge binary version",
RunE: func(cmd *cobra.Command, _ []string) error {
fmt.Fprintln(cmd.OutOrStdout(), version.Version)
return nil
},
}
}