update roadmap and edge app files

This commit is contained in:
toki 2026-05-28 09:27:34 +09:00
parent 433c84f002
commit 62c28d7f1c
5 changed files with 159 additions and 46 deletions

View file

@ -14,7 +14,7 @@ Edge/Node 바이너리는 repo checkout 위치에 묶이지 않고, 바이너리
## 상태
[진행중]
[완료]
## 승격 조건
@ -48,24 +48,26 @@ Control Plane이 붙기 전까지 Edge 바이너리와 `edge.yaml`만으로 개
- [x] [config-discovery] `iop-edge``--config`가 없을 때 repo 상대 `configs/edge.yaml`에 의존하지 않고, 바이너리와 같은 폴더의 `edge.yaml` 같은 배포용 기본 config 후보를 우선 사용할 수 있다. 검증: 임시 폴더에 `iop-edge``edge.yaml`만 둔 상태에서 `iop-edge config check``iop-edge serve`가 동작한다.
- [x] [config-template] `iop-edge`는 배포용 `edge.yaml` 템플릿을 stdout 또는 바이너리 옆 파일로 생성하는 command를 제공한다. 검증: 빈 임시 폴더에서 `iop-edge`만으로 템플릿을 생성하고, 생성된 파일이 `iop-edge config check`를 통과한다.
- [x] [env-report] `iop-edge`는 현재 사용할 config path, binary dir, listen 주소, advertise host 판별 결과, Node transport 주소, artifact/bootstrap 주소, OpenAI-compatible 주소, configured/connected node 요약을 출력할 수 있다. 검증: `iop-edge` 단일 command 출력만 보고 사용자가 Cline 주소와 Node bootstrap 주소를 알 수 있다.
- [ ] [advertise-host] `edge.yaml`의 advertise host 기본값은 빈 값이며, 비어 있으면 host 기준 IP/host를 자동 판별하고, 값이 있으면 자동 판별보다 설정값을 우선한다. 검증: 빈 값/명시 값 각각에서 bootstrap command와 environment 출력의 host가 기대대로 달라진다.
- [x] [advertise-host] `edge.yaml`의 advertise host 기본값은 빈 값이며, 비어 있으면 host 기준 IP/host를 자동 판별하고, 값이 있으면 자동 판별보다 설정값을 우선한다. 검증: 빈 값/명시 값 각각에서 bootstrap command와 environment 출력의 host가 기대대로 달라진다.
- [x] [log-defaults] `iop-edge`는 로컬/dev bundle에서 로그 경로가 비어 있으면 바이너리 폴더 아래의 log 디렉터리를 자동 사용하고, `env`/help 출력에 실제 로그 위치를 보여준다. 검증: clean temp dir에서 실행하면 `<binary-dir>/logs/` 아래에 edge 로그가 남고, 설정값이 있으면 명시 로그 경로를 따른다.
- [x] [edge-source] `edge.yaml`은 Control Plane 없는 개발 단계의 source of truth로서 `nodes[]`에 Node id, alias, token, adapter, runtime 설정을 담는다. 검증: `iop-edge config check``nodes[]` token/id/alias 충돌과 adapter/runtime 설정 오류를 잡는다.
- [x] [node-register] `iop-edge node register <node-id>`는 Node 정의와 Node별 adapter/runtime 설정을 flag/default로 받아 `edge.yaml``nodes[]`에 생성 또는 갱신하고, 실행 직후 해당 Node host에서 사용할 완성된 bootstrap 명령을 출력한다. 검증: 빈 템플릿에서 `iop-edge node register node-silicon-ollama` 실행 후 `edge.yaml`에 node record가 생기고 출력에는 사용자가 실행할 한 줄 bootstrap만 남는다.
- [ ] [help-discovery] `iop-edge help`와 관련 하위 command help는 사용자가 문서를 열지 않아도 `config init`, `config check`, environment 출력, Node register/bootstrap 출력, `serve`, smoke test의 순서와 필수/선택 인자를 알 수 있게 설명한다. 검증: `iop-edge help`, `iop-edge config --help`, `iop-edge node --help`, `iop-edge node register --help`, `iop-edge smoke --help` 출력에 이 Milestone의 공식 사용자 경로가 드러난다.
- [x] [help-discovery] `iop-edge help`와 관련 하위 command help는 사용자가 문서를 열지 않아도 `config init`, `config check`, environment 출력, Node register/bootstrap 출력, `serve`, smoke test의 순서와 필수/선택 인자를 알 수 있게 설명한다. 검증: `iop-edge help`, `iop-edge config --help`, `iop-edge node --help`, `iop-edge node register --help`, `iop-edge smoke --help` 출력에 이 Milestone의 공식 사용자 경로가 드러난다.
- [x] [edge-commands] Node 등록 관리, bootstrap command 발급, local/dev setup, 실행 전 config 확인, smoke test 진입점은 공식 사용자 경로에서 `iop-edge` command로 제공된다. 검증: 문서와 help output에서 `bin/*`, 별도 dev deploy 바이너리, 모델용 skill이 공식 경로로 등장하지 않는다.
- [x] [node-hidden-config] Node 사용자 UX는 Edge가 제시한 bootstrap 한 줄로 끝나며 `node.yaml` 생성, 편집, 확인 또는 `iop-node serve --config ...` 직접 실행을 요구하지 않는다. 검증: 사용자용 dev/field 가이드에 node config 파일 조작 단계가 없다.
- [ ] [bundle-run] Edge/Node dev bundle은 repo checkout 위치와 무관하게 바이너리 옆 config/artifact만으로 실행 가능하다. 검증: clean temp dir 또는 개발 서버 배포 디렉터리에서 Edge 실행, Node bootstrap, Node registration, `/v1/models` smoke가 통과한다.
- [x] [bundle-run] Edge/Node dev bundle은 repo checkout 위치와 무관하게 바이너리 옆 config/artifact만으로 실행 가능하다. 검증: clean temp dir 또는 개발 서버 배포 디렉터리에서 Edge 실행, Node bootstrap, Node registration, `/v1/models` smoke가 통과한다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: `config-discovery`, `config-template`, `env-report`, `log-defaults`, `edge-source`, `node-register`, `edge-commands`, `node-hidden-config`는 코드/문서/테스트 근거가 확인되었다. 남은 기능 Task와 Task 안의 검증이 아직 모두 충족되지는 않았다.
- 상태: 승인됨
- 요청일: 2026-05-28
- 완료 근거: 모든 기능 Task와 Task 안의 검증이 충족되었다.
- 완료 근거: `go test ./...`가 통과했고, `apps/edge/cmd/edge` 테스트가 advertise host 기반 bootstrap URL, 하위 help discovery, config/node/smoke 흐름을 검증한다.
- 완료 근거: clean temp bundle에서 field binary build, Edge serve, artifact HTTP, Node bootstrap registration, `/v1/models` 확인이 통과했다.
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: `advertise-host`, `help-discovery`, `bundle-run` 검증이 남아 있어 완료 리뷰 요청 전이다.
- [x] 사용자가 완료 결과를 확인했다
- [x] archive 이동을 승인했다
- 리뷰 코멘트: 2026-05-28 사용자 요청으로 완료 처리하고 archive로 이동했다.
## 범위 제외

View file

@ -7,9 +7,9 @@
## 활성 Milestone
- [진행중] Edge-local Dev Config와 바이너리 중심 실행 루프
- [계획] Client Integration Clone Adoption
- Phase: `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md`
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md`
## 선택 규칙

View file

@ -17,8 +17,8 @@
- 경로: `agent-roadmap/archive/phase/control-plane-portal-ops/milestones/flutter-first-portal-migration.md`
- 요약: Next.js `apps/web` scaffold를 제거하고 `apps/portal` Flutter 앱, Flutter Web compose 배포 흐름, Portal-Control Plane proto-socket WS hello 기반을 세웠다.
- [진행중] Edge-local Dev Config와 바이너리 중심 실행 루프
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md`
- [완료] Edge-local Dev Config와 바이너리 중심 실행 루프
- 경로: `agent-roadmap/archive/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md`
- 요약: Control Plane이 없는 동안 `edge.yaml`을 테스트용 source of truth로 삼고, config 템플릿 생성, effective environment 출력, Node register/bootstrap 출력, setup, smoke test를 `iop-edge` command 중심으로 모은다.
- [계획] Client Integration Clone Adoption

View file

@ -197,6 +197,18 @@ 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
@ -361,6 +373,14 @@ func addressFor(host, listen string) string {
return net.JoinHostPort(host, port)
}
func defaultArtifactBaseURL(host string) string {
return "http://" + addressFor(host, "0.0.0.0:18080")
}
func bootstrapScriptURL(baseURL, target string) string {
return strings.TrimSuffix(baseURL, "/") + "/bootstrap/node-" + target + ".sh"
}
type envReport struct {
ConfigPath string
BinaryDir string
@ -374,16 +394,20 @@ type envReport struct {
}
func renderEnv(w io.Writer, r envReport) {
artifactURL := strings.TrimSuffix(r.Bootstrap.ArtifactBaseURL, "/")
artifactSource := "configured"
if artifactURL == "" {
artifactURL = defaultArtifactBaseURL(r.Advertise.Host)
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))
if r.Bootstrap.ArtifactBaseURL != "" {
fmt.Fprintf(w, "artifact_base_url: %s\n", r.Bootstrap.ArtifactBaseURL)
} else {
fmt.Fprintln(w, "artifact_base_url: (unset)")
}
fmt.Fprintf(w, "artifact_base_url: %s (%s)\n", artifactURL, artifactSource)
fmt.Fprintf(w, "node_bootstrap: %s\n", bootstrapScriptURL(artifactURL, runtime.GOOS+"-"+runtime.GOARCH))
if r.OpenAI.Enabled {
fmt.Fprintf(w, "openai_url: http://%s/v1\n", addressFor(r.Advertise.Host, r.OpenAI.Listen))
} else {
@ -498,6 +522,14 @@ 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
@ -507,7 +539,16 @@ func nodeRegisterCmd() *cobra.Command {
c := &cobra.Command{
Use: "register [node-id]",
Short: "Register or update a node record",
Args: cobra.ExactArgs(1),
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 --target linux-amd64
iop-edge config check`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
nodeID := args[0]
@ -661,7 +702,7 @@ func nodeRegisterCmd() *cobra.Command {
cfg.Bootstrap.ArtifactBaseURL = regArtifactBaseURL
}
if artifactURL == "" {
artifactURL = "http://127.0.0.1:18080" // fallback default
artifactURL = defaultArtifactBaseURL(resolveAdvertiseHost(cfg.Server.AdvertiseHost).Host)
}
// Validate mutated config before writing
@ -678,15 +719,8 @@ func nodeRegisterCmd() *cobra.Command {
return fmt.Errorf("write config file: %w", err)
}
actionStr := "Registered"
if exists {
actionStr = "Updated"
}
fmt.Fprintf(cmd.OutOrStdout(), "%s node %s\n", actionStr, nodeID)
// Render final user-facing bootstrap command
artifactURL = strings.TrimSuffix(artifactURL, "/")
bootstrapURL := fmt.Sprintf("%s/bootstrap/node-%s.sh", artifactURL, target)
bootstrapURL := bootstrapScriptURL(artifactURL, target)
fmt.Fprintf(cmd.OutOrStdout(), "curl -fsSL %s | bash -s %s\n", bootstrapURL, nodeDef.Token)
return nil
@ -840,7 +874,15 @@ 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.`,
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
@ -1064,4 +1106,3 @@ If --base-url is not provided, it auto-discovers the OpenAI URL from the effecti
return c
}

View file

@ -357,8 +357,13 @@ func TestEnvCmdMarksAutoAdvertiseHostAndOfflineStateForEmptyConfig(t *testing.T)
if !strings.Contains(got, "advertise_host: ") || !strings.Contains(got, " (auto)") {
t.Errorf("expected advertise_host with (auto) source, got:\n%s", got)
}
if !strings.Contains(got, "artifact_base_url: (unset)") {
t.Errorf("expected artifact_base_url (unset), 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: " + bootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host), runtime.GOOS+"-"+runtime.GOARCH)
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)
@ -529,13 +534,10 @@ func TestNodeRegisterUpsertsYAMLAndOutputsBootstrap(t *testing.T) {
}
output := out.String()
if !strings.Contains(output, "Registered node node-a") {
t.Errorf("expected 'Registered node node-a', got %q", output)
}
// Check one-line bootstrap output
expectedCmd := "curl -fsSL http://127.0.0.1:18080/bootstrap/node-" + runtime.GOOS + "-" + runtime.GOARCH + ".sh | bash -s mocked-token-1234"
if !strings.Contains(output, expectedCmd) {
t.Errorf("expected bootstrap command %q, got %q", expectedCmd, output)
expectedCmd := "curl -fsSL " + bootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host), runtime.GOOS+"-"+runtime.GOARCH) + " | 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
@ -567,12 +569,9 @@ func TestNodeRegisterUpsertsYAMLAndOutputsBootstrap(t *testing.T) {
}
output = out.String()
if !strings.Contains(output, "Updated node node-a") {
t.Errorf("expected 'Updated node node-a', got %q", output)
}
expectedCmd2 := "curl -fsSL http://127.0.0.1:18080/bootstrap/node-" + runtime.GOOS + "-" + runtime.GOARCH + ".sh | bash -s explicit-token-abc"
if !strings.Contains(output, expectedCmd2) {
t.Errorf("expected bootstrap command %q, got %q", expectedCmd2, output)
expectedCmd2 := "curl -fsSL " + bootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host), runtime.GOOS+"-"+runtime.GOARCH) + " | 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
@ -763,6 +762,35 @@ bootstrap:
}
}
func TestNodeRegisterUsesAdvertiseHostForDerivedArtifactBaseURL(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
yamlStr := `server:
listen: "0.0.0.0:9090"
advertise_host: "edge.example.test"
bootstrap:
artifact_base_url: ""
`
if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-test", "--target", "linux-amd64", "--token", "test-token"})
if err := root.Execute(); err != nil {
t.Fatalf("execute register: %v\n%s", err, out.String())
}
expectedCmd := "curl -fsSL http://edge.example.test:18080/bootstrap/node-linux-amd64.sh | bash -s test-token"
if !strings.Contains(out.String(), expectedCmd) {
t.Errorf("expected bootstrap command %q, got %q", expectedCmd, out.String())
}
}
func TestTempFileCLIWorkflowConfigInitNodeRegisterConfigCheck(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
@ -931,6 +959,49 @@ func TestHelpDiscoversEdgeLocalDevFlow(t *testing.T) {
t.Errorf("root help contains legacy path reference: %q", unwanted)
}
}
helpCases := []struct {
name string
args []string
want []string
}{
{
name: "config",
args: []string{"config", "--help"},
want: []string{"config init", "iop-edge env", "node register", "config check", "serve"},
},
{
name: "node",
args: []string{"node", "--help"},
want: []string{"node register", "edge.yaml", "one-line bootstrap command", "config check"},
},
{
name: "node register",
args: []string{"node", "register", "--help"},
want: []string{"one-line bootstrap command", "node.yaml", "iop-node serve", "--adapter", "--target"},
},
{
name: "smoke",
args: []string{"smoke", "--help"},
want: []string{"smoke openai", "config init", "node register", "serve"},
},
}
for _, tc := range helpCases {
root := rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(tc.args)
if err := root.Execute(); err != nil {
t.Fatalf("%s help execute failed: %v", tc.name, err)
}
got := out.String()
for _, w := range tc.want {
if !strings.Contains(got, w) {
t.Errorf("%s help output missing %q\n---\n%s", tc.name, w, got)
}
}
}
}
func TestNodesListCommand(t *testing.T) {
@ -1103,4 +1174,3 @@ func TestSmokeOpenAICommandFailure(t *testing.T) {
t.Errorf("unexpected error message: %v", err3)
}
}