feat: edge/node architecture updates and agent-task integration

- Add node store implementation for edge app
- Add adapters factory for node app
- Update edge and node transport layers
- Update domain rules for edge and node
- Add bin scripts for edge and node
- Update configs and documentation
- Add agent-task node_centralized_mgmt directory
This commit is contained in:
toki 2026-05-03 10:51:29 +09:00
parent e6d5254a6e
commit b4c6550eab
39 changed files with 3385 additions and 398 deletions

View file

@ -28,4 +28,4 @@ proto:
proto/iop/job.proto
clean:
rm -rf bin/
rm -f bin/node bin/edge bin/control-plane bin/worker

View file

@ -2,14 +2,14 @@
## 목적 / 책임
외부 API gateway로 발전할 앱 영역이며, 현재는 node 연결을 수락하고 capability를 조회하여 registry에 등록하는 TCP/protobuf 서버 책임을 가진다.
외부 API gateway로 발전할 앱 영역이며, 현재는 node 연결을 수락하고 token 기반 등록을 검증한 뒤 중앙 관리 설정을 내려주는 TCP/protobuf 서버 책임을 가진다.
## 포함 경로
- `apps/edge/cmd/edge/` — edge CLI 진입점과 serve 커맨드
- `apps/edge/internal/bootstrap/` — fx 의존성 주입과 서버 시작/종료 lifecycle
- `apps/edge/internal/node/` — 연결된 node registry와 node 선택
- `apps/edge/internal/transport/` — node 연결 수락, capability 요청, run event 수신
- `apps/edge/internal/transport/` — node 연결 수락, RegisterRequest/Response 처리, run event 수신
- `apps/edge/README.md` — edge 계획과 현재 placeholder 범위
## 제외 경로
@ -24,19 +24,19 @@
- `transport.Server` — proto-socket TCP 서버 wrapper
- `node.Registry` — 연결된 node의 `NodeEntry` 저장/조회
- `node.NodeEntry` — node ID, TCP client, adapter capability 정보
- `node.NodeEntry` — node ID, alias, TCP client 정보
- `bootstrap.Module` — edge 서버와 metrics lifecycle 구성
## 유지할 패턴
- edge는 node 연결 상태와 capability를 registry에 반영하는 역할을 먼저 안정화한다.
- edge는 사전 등록된 node 정의를 검증하고 연결 상태를 registry에 반영하는 역할을 먼저 안정화한다.
- 외부 OpenAI-compatible HTTP API는 node/transport 안정화 이후 확장한다.
- 연결 직후 capability request를 보내고 실패 시 연결을 정리하는 흐름을 유지한다.
- node가 연결 직후 RegisterRequest를 보내고 edge가 RegisterResponse로 중앙 설정을 응답하는 흐름을 유지한다.
- registry는 동시성 안전성을 유지하고 외부로 내부 map을 노출하지 않는다.
## 다른 도메인과의 경계
- **node**: edge는 node를 실행하지 않는다. node capability를 기반으로 요청을 보낼 대상만 선택한다.
- **node**: edge는 node를 실행하지 않는다. edge는 사전 등록 정보와 연결 registry를 기반으로 요청을 보낼 대상만 선택한다.
- **platform-common**: edge 설정, metrics, protobuf 타입은 platform-common 계약을 따른다.
- **control-plane 후보**: 장기적으로 노드 등록/정책/스케줄링이 control-plane으로 분리될 수 있으므로 edge에 해당 책임을 굳히기 전에 경계를 재검토한다.

View file

@ -42,7 +42,7 @@
## 다른 도메인과의 경계
- **edge**: edge는 node 연결 등록과 capability 조회/라우팅 진입을 담당한다. node는 edge가 보낸 실행 요청을 처리하고 이벤트를 돌려준다.
- **edge**: edge는 node 연결 등록과 중앙 설정 전달, 라우팅 진입을 담당한다. node는 edge가 보낸 실행 요청을 처리하고 이벤트를 돌려준다.
- **platform-common**: node는 `packages/config`, `packages/observability`, `proto/gen/iop` 등을 사용하지만 공통 타입/설정 자체의 소유자는 platform-common이다.
- **control-plane 후보**: 정책/스케줄링의 시스템 단위 결정은 control-plane에서 다루고, node는 전달받은 실행 단위 수행에 집중한다.

View file

@ -8,13 +8,13 @@
## 프로젝트 개요
- IOP(Inference Operations Platform)는 분산 AI 모델 추론 워크로드를 처리하는 Go 모노레포이다.
- 현재 1차 구현 중심은 `apps/node`이며, `apps/edge`는 node 연결/레지스트리/transport가 일부 구현되어 있다.
- 현재 1차 구현 중심은 `apps/node`이며, `apps/edge`는 node 등록/레지스트리/transport가 일부 구현되어 있다.
- `apps/control-plane``apps/worker`는 README와 CLI placeholder 수준이므로, 본격 구현 전 별도 domain rule을 만들거나 갱신한다.
## 주요 구조
- `apps/node/` — 디바이스별 노드 에이전트. 런타임 라우팅, 어댑터 실행, edge 연결, 실행 이력 저장을 담당한다.
- `apps/edge/` — node 연결을 받아 등록하고 capability를 조회하는 gateway 서버 영역이다.
- `apps/edge/` — node 연결을 받아 token 기반 등록을 처리하고 중앙 설정을 내려주는 gateway 서버 영역이다.
- `apps/control-plane/` — 노드 등록, 정책, 스케줄링 예정 영역이다. 현재 placeholder이다.
- `apps/worker/` — 비동기 작업 처리 예정 영역이다. 현재 placeholder이다.
- `packages/` — 설정, 인증, 정책, 메타데이터, 작업, 관측성, 버전 등 공통 패키지이다.

View file

@ -0,0 +1,264 @@
<!-- task=node_centralized_mgmt plan=0 tag=REFACTOR -->
# Code Review Reference - REFACTOR
## 개요
date=2026-05-03
task=node_centralized_mgmt, plan=0, tag=REFACTOR
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md` → `code_review_0.log` (N = 기존 code_review_*.log 수)
2. `PLAN.md` → `plan_0.log` (M = 기존 plan_*.log 수)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] proto: RegisterRequest / RegisterResponse / NodeConfigPayload 추가 | [x] |
| [REFACTOR-2] packages/config: NodeConfig 단순화 + EdgeConfig에 노드 정의 추가 | [x] |
| [REFACTOR-3] edge: NodeStore 신설 + NodeEntry 갱신 | [x] |
| [REFACTOR-4] edge: transport 핸드셰이크 변경 + bootstrap 재배선 | [x] |
| [REFACTOR-5] node: transport 핸드셰이크 변경 + DialEdge 시그니처 변경 | [x] |
| [REFACTOR-6] node: adapter factory + bootstrap 재구성 | [x] |
| [REFACTOR-7] 테스트 전체 갱신 | [x] |
## 계획 대비 변경 사항
- 실행 환경에 `make`가 없어 `make proto`는 실패했다. Makefile의 `proto` 타깃과 동일한 `protoc` 명령을 `PATH="/config/go/bin:$PATH"`로 직접 실행해 생성물을 갱신했다.
- edge 등록 처리에서 동일 node ID가 이미 registry에 있으면 `node already connected`로 거절하도록 했다. 중앙 관리 전환의 중복 접속 방지 목적에 맞춘 보강이다.
- node bootstrap에서 edge가 내려준 `workspace_root` 디렉터리를 생성하고, OnStop에서 SQLite store를 닫도록 했다. 런타임 시작 실패와 리소스 누수를 줄이기 위한 보강이다.
## 주요 설계 결정
- edge의 `NodeStore`는 config 기반 사전 등록 정보를 token/ID로 보관하고, runtime `Registry`는 현재 연결된 `NodeEntry{node_id, alias, client}`만 보관한다.
- node는 `DialEdge(edge_addr, token)`에서 `RegisterRequest`를 먼저 보내고, `RegisterResponse`가 accepted일 때만 `Session`과 중앙 설정 payload를 사용한다.
- `Session`은 registration 이후 생성되는 `node.Node`를 받을 수 있도록 mutex 보호 `SetHandler`를 제공한다.
- `adapters.BuildFromPayload`는 mock adapter를 항상 등록하고, edge payload에 포함된 enabled adapter만 추가 등록한다.
## 리뷰어를 위한 체크포인트
- `RegisterRequest`/`RegisterResponse`가 proto에 정확히 추가되었는지, `make proto` 후 컴파일 오류가 없는지 확인
- `CapabilityRequest`/`CapabilityResponse`가 proto에서 삭제되지 않고 유지되었는지 확인
- edge `onNodeConnected`에서 더 이상 `CapabilityRequest`를 전송하지 않는지 확인
- node `DialEdge`가 연결 직후 `RegisterRequest`를 전송하고 응답을 검증하는지 확인
- `Session.SetHandler`가 mutex로 보호되는지 확인
- `node.Node.OnCapabilityRequest`가 삭제되었는지, `Handler` 인터페이스에도 없는지 확인
- `adapters.BuildFromPayload`가 mock을 항상 등록하고 타입별 변환이 정확한지 확인
- `node.New`가 `*config.NodeConfig` 대신 `nodeID string`을 직접 받는지 확인
- `NodeStore.FindByToken`이 등록되지 않은 token에 대해 false를 반환하는지 확인
- `configs/node.yaml`에 edge_addr + token 외 항목이 없는지 확인
- 모든 테스트가 새 handshake 기반으로 갱신되었는지 확인
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### REFACTOR-1 중간 검증
```
$ make proto
/usr/bin/bash: line 1: make: command not found
$ PATH="/config/go/bin:$PATH" protoc --go_out=. --go_opt=module=iop --proto_path=. proto/iop/runtime.proto proto/iop/node.proto proto/iop/control.proto proto/iop/job.proto
(no output)
$ go build ./...
(no output)
```
### REFACTOR-2 중간 검증
```
$ go build ./packages/config/...
(no output)
```
### REFACTOR-3 중간 검증
```
$ go test ./apps/edge/internal/node/...
ok iop/apps/edge/internal/node 0.003s
```
### REFACTOR-4 중간 검증
```
$ go build ./apps/edge/...
(no output)
```
### REFACTOR-5 중간 검증
```
$ go build ./apps/node/internal/transport/...
(no output)
```
### REFACTOR-6 중간 검증
```
$ go build ./apps/node/...
(no output)
```
### REFACTOR-7 중간 검증
```
$ go test ./apps/edge/internal/node/...
ok iop/apps/edge/internal/node 0.003s
$ go test ./apps/node/internal/node/...
ok iop/apps/node/internal/node 0.005s
$ go test ./apps/node/internal/adapters/...
ok iop/apps/node/internal/adapters 0.003s
? iop/apps/node/internal/adapters/cli [no test files]
? iop/apps/node/internal/adapters/mock [no test files]
? iop/apps/node/internal/adapters/ollama [no test files]
? iop/apps/node/internal/adapters/vllm [no test files]
$ go test ./apps/edge/internal/transport/...
ok iop/apps/edge/internal/transport 0.004s
$ go test ./apps/node/internal/transport/...
ok iop/apps/node/internal/transport 0.004s
```
### 최종 검증 (리뷰어 재실행)
```
$ go test -count=1 ./...
ok iop/apps/edge/internal/node 0.003s
ok iop/apps/edge/internal/transport 0.005s
ok iop/apps/node/internal/adapters 0.003s
ok iop/apps/node/internal/node 0.005s
ok iop/apps/node/internal/transport 0.005s
```
### 최종 검증
```
$ make proto && go build ./... && go test ./...
/usr/bin/bash: line 1: make: command not found
$ PATH="/config/go/bin:$PATH" protoc --go_out=. --go_opt=module=iop --proto_path=. proto/iop/runtime.proto proto/iop/node.proto proto/iop/control.proto proto/iop/job.proto
(no output)
$ go build ./...
(no output)
$ go test ./...
? iop/apps/control-plane/cmd/control-plane [no test files]
? iop/apps/edge/cmd/edge [no test files]
? iop/apps/edge/internal/bootstrap [no test files]
ok iop/apps/edge/internal/node (cached)
ok iop/apps/edge/internal/transport (cached)
? iop/apps/node/cmd/node [no test files]
ok iop/apps/node/internal/adapters (cached)
? iop/apps/node/internal/adapters/cli [no test files]
? iop/apps/node/internal/adapters/mock [no test files]
? iop/apps/node/internal/adapters/ollama [no test files]
? iop/apps/node/internal/adapters/vllm [no test files]
? iop/apps/node/internal/bootstrap [no test files]
ok iop/apps/node/internal/node (cached)
? iop/apps/node/internal/router [no test files]
? iop/apps/node/internal/runtime [no test files]
? iop/apps/node/internal/store [no test files]
ok iop/apps/node/internal/transport (cached)
? iop/apps/worker/cmd/worker [no test files]
? iop/packages/auth [no test files]
? iop/packages/config [no test files]
? iop/packages/jobs [no test files]
? iop/packages/metadata [no test files]
? iop/packages/observability [no test files]
? iop/packages/policy [no test files]
? iop/packages/version [no test files]
? iop/proto/gen/iop [no test files]
$ go test -count=1 ./...
? iop/apps/control-plane/cmd/control-plane [no test files]
? iop/apps/edge/cmd/edge [no test files]
? iop/apps/edge/internal/bootstrap [no test files]
ok iop/apps/edge/internal/node 0.003s
ok iop/apps/edge/internal/transport 0.005s
? iop/apps/node/cmd/node [no test files]
ok iop/apps/node/internal/adapters 0.003s
? iop/apps/node/internal/adapters/cli [no test files]
? iop/apps/node/internal/adapters/mock [no test files]
? iop/apps/node/internal/adapters/ollama [no test files]
? iop/apps/node/internal/adapters/vllm [no test files]
? iop/apps/node/internal/bootstrap [no test files]
ok iop/apps/node/internal/node 0.005s
? iop/apps/node/internal/router [no test files]
? iop/apps/node/internal/runtime [no test files]
? iop/apps/node/internal/store [no test files]
ok iop/apps/node/internal/transport 0.005s
? iop/apps/worker/cmd/worker [no test files]
? iop/packages/auth [no test files]
? iop/packages/config [no test files]
? iop/packages/jobs [no test files]
? iop/packages/metadata [no test files]
? iop/packages/observability [no test files]
? iop/packages/policy [no test files]
? iop/packages/version [no test files]
? iop/proto/gen/iop [no test files]
```
---
## 코드리뷰 결과
### 종합 판정: FAIL
### 차원별 평가
| 차원 | 판정 | 비고 |
|------|------|------|
| Correctness | Fail | CLI 어댑터 args/env `[]string` → structpb 오류 무시 |
| Completeness | Pass | 계획 7개 항목 전부 구현 |
| Test coverage | Warn | CLI 어댑터 config 직렬화 경로 미검증 |
| API contract | Pass | Handler 인터페이스, DialEdge 시그니처, 모든 호출부 일치 |
| Code quality | Pass | 불필요한 주석 없음, 잔여 TODO 없음 |
| Plan deviation | Pass | 계획 대비 변경은 모두 합리적 보강 |
| Verification trust | Pass | 검증 출력이 실제 코드와 일치 |
### 발견된 문제
- **Required** `apps/edge/internal/transport/server.go:160-161`
`buildConfigPayload`에서 CLI 프로필의 `Args`/`Env` 필드를 `[]string` 타입 그대로 `structpb.NewStruct`에 전달한다.
`structpb.NewValue`는 `[]interface{}`만 처리하고 `[]string`은 `proto: invalid type: []string` 오류를 반환한다.
`st, _ := structpb.NewStruct(settings)`로 오류를 무시하므로 `st`가 `nil`이 되어 CLI 어댑터 설정(command, args, env)이 전부 소실된다. 노드에서 CLI 어댑터가 빈 profile 맵으로 초기화되어 모든 실행 요청이 "unknown profile" 오류로 실패한다.
수정:
```go
// buildConfigPayload 내 CLI 처리 부분
toAnySlice := func(ss []string) []interface{} {
out := make([]interface{}, len(ss))
for i, s := range ss {
out[i] = s
}
return out
}
profiles[name] = map[string]any{
"command": p.Command,
"args": toAnySlice(p.Args),
"env": toAnySlice(p.Env),
}
```
아울러 `buildConfigPayload`가 오류를 반환할 수 있도록 시그니처를 `func buildConfigPayload(rec *edgenode.NodeRecord) (*iop.NodeConfigPayload, error)`로 변경하고, `onNodeConnected`에서 오류 발생 시 `RegisterResponse{Accepted: false, Reason: ...}`를 응답한다.
- **Suggested** `apps/edge/internal/node/store.go:LoadFromConfig`
중복 token은 검사하지만 중복 alias는 검사하지 않는다. Alias가 동일하면 `"node-" + alias`로 파생되는 ID가 충돌하고, `byID` 맵에서 후자가 전자를 덮어쓴다. 운영자가 실수로 동일 alias를 두 번 등록하면 한 노드가 silent하게 소실된다.
수정: `seen` 맵을 alias에도 적용한다.
```go
seenAlias := make(map[string]bool)
// ...
if seenAlias[d.Alias] {
return nil, fmt.Errorf("node[%d] alias=%q: duplicate alias", i, d.Alias)
}
seenAlias[d.Alias] = true
```
### 다음 단계
FAIL: Required 이슈 수정을 위한 새 PLAN.md를 작성한다.

View file

@ -0,0 +1,126 @@
<!-- task=node_centralized_mgmt plan=1 tag=REVIEW_REFACTOR -->
# Code Review Reference - REVIEW_REFACTOR
## 개요
date=2026-05-03
task=node_centralized_mgmt, plan=1, tag=REVIEW_REFACTOR
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md` → `code_review_1.log` (N = 기존 code_review_*.log 수)
2. `PLAN.md` → `plan_1.log` (M = 기존 plan_*.log 수)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REFACTOR-1] buildConfigPayload: []string → structpb 오류 수정 | [x] |
| [REVIEW_REFACTOR-2] LoadFromConfig: 중복 alias 검사 추가 | [x] |
## 계획 대비 변경 사항
- `buildConfigPayload` 오류 발생 시 거절 응답을 반환하기 전에 registry에 등록되지 않도록, payload 생성 성공 이후 `registry.Register`를 수행하게 순서를 정리했다.
## 주요 설계 결정
- CLI profile의 `args`/`env`는 `structpb.NewStruct`가 처리 가능한 `[]interface{}`로 변환한다.
- `structpb.NewStruct` 오류는 더 이상 무시하지 않고 `RegisterResponse{Accepted:false, Reason:"internal config error"}`로 전파한다.
- alias 중복은 token 중복과 별도로 검사해 `"node-" + alias` 기반 ID 충돌을 차단한다.
## 리뷰어를 위한 체크포인트
- `buildConfigPayload`가 `(*iop.NodeConfigPayload, error)` 를 반환하는지 확인
- `stringsToAny` 헬퍼가 추가되어 CLI args/env에 사용되는지 확인
- `structpb.NewStruct` 오류가 더 이상 `_`로 무시되지 않는지 확인
- `onNodeConnected` 내에서 `buildConfigPayload` 오류 시 `Accepted: false` 응답하는지 확인
- `LoadFromConfig`에 alias 중복 검사가 추가되었는지 확인
- `TestBuildConfigPayload_CLIArgsRoundtrip` 테스트가 존재하고 args/env 값을 검증하는지 확인
- `TestLoadFromConfig_DuplicateAlias` 테스트가 존재하는지 확인
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### REVIEW_REFACTOR-1 중간 검증
```
$ go build ./apps/edge/internal/transport/...
(no output)
$ go test ./apps/edge/internal/transport/...
ok iop/apps/edge/internal/transport 0.005s
```
### REVIEW_REFACTOR-2 중간 검증
```
$ go test ./apps/edge/internal/node/...
ok iop/apps/edge/internal/node 0.003s
```
### 최종 검증
```
$ go build ./... && go test ./...
$ go build ./...
(no output)
$ go test ./...
? iop/apps/control-plane/cmd/control-plane [no test files]
? iop/apps/edge/cmd/edge [no test files]
? iop/apps/edge/internal/bootstrap [no test files]
ok iop/apps/edge/internal/node (cached)
ok iop/apps/edge/internal/transport (cached)
? iop/apps/node/cmd/node [no test files]
ok iop/apps/node/internal/adapters (cached)
? iop/apps/node/internal/adapters/cli [no test files]
? iop/apps/node/internal/adapters/mock [no test files]
? iop/apps/node/internal/adapters/ollama [no test files]
? iop/apps/node/internal/adapters/vllm [no test files]
? iop/apps/node/internal/bootstrap [no test files]
ok iop/apps/node/internal/node (cached)
? iop/apps/node/internal/router [no test files]
? iop/apps/node/internal/runtime [no test files]
? iop/apps/node/internal/store [no test files]
ok iop/apps/node/internal/transport (cached)
? iop/apps/worker/cmd/worker [no test files]
? iop/packages/auth [no test files]
? iop/packages/config [no test files]
? iop/packages/jobs [no test files]
? iop/packages/metadata [no test files]
? iop/packages/observability [no test files]
? iop/packages/policy [no test files]
? iop/packages/version [no test files]
? iop/proto/gen/iop [no test files]
```
---
## 코드리뷰 결과
### 종합 판정: PASS
### 차원별 평가
| 차원 | 판정 | 비고 |
|------|------|------|
| Correctness | Pass | `[]string` → `[]interface{}` 변환 정확히 수정, 오류 전파 올바름 |
| Completeness | Pass | 계획 2개 항목 전부 구현 |
| Test coverage | Pass | `TestBuildConfigPayload_CLIArgsRoundtrip` args/env 값까지 검증, `TestLoadFromConfig_DuplicateAlias` 추가 |
| API contract | Pass | `buildConfigPayload` 시그니처 변경이 유일한 호출부 `onNodeConnected`에 반영됨 |
| Code quality | Pass | `stringsToAny` 범용 헬퍼로 분리, internal test로 unexported 함수 직접 검증 |
| Plan deviation | Pass | `registry.Register`를 payload 빌드 성공 이후로 이동 — 올바른 순서 개선 |
| Verification trust | Pass | 검증 출력이 실제 코드와 일치, 테스트 모두 PASS 재확인 |
### 발견된 문제
없음
### 다음 단계
PASS: `complete.log`를 작성하고 태스크를 종료한다.

View file

@ -0,0 +1,29 @@
완료 일시: 2026-05-03
요약: Jenkins 스타일 노드 중앙 관리 구조 리팩토링 — plan×2, loop×2
루프 이력:
| plan | code_review | 판정 |
|------|-------------|------|
| plan_0.log | code_review_0.log | FAIL |
| plan_1.log | code_review_1.log | PASS |
최종 리뷰 요약:
- proto/iop/runtime.proto에 RegisterRequest, RegisterResponse, NodeConfigPayload, AdapterConfig, NodeRuntimeConfig 추가
- packages/config: NodeConfig를 transport+logging+metrics로 단순화, EdgeConfig에 Nodes []NodeDefinition 추가, TransportConf에 Token 추가
- configs/node.yaml: edge_addr+token+logging+metrics만 남김 / configs/edge.yaml: nodes 섹션 추가
- apps/edge/internal/node/store.go 신규: NodeStore(token/ID 기반 조회), LoadFromConfig(token·alias 중복 검사)
- apps/edge/internal/node/registry.go: NodeEntry에서 Adapters 제거, Alias 추가
- apps/edge/internal/transport/server.go: CapabilityRequest 핸드셰이크 → RegisterRequest/Response 핸드셰이크, buildConfigPayload(오류 반환형), stringsToAny, 중복 접속 차단
- apps/edge/internal/bootstrap/module.go: NodeStore provider 추가, OnStart에서 config 기반 시드
- apps/node/internal/transport/session.go: Handler에서 OnCapabilityRequest 제거, SetHandler(mutex) 추가
- apps/node/internal/transport/parser.go: CapabilityRequest 제거, RegisterResponse 추가
- apps/node/internal/transport/client.go: DialEdge → RegisterResult 반환형으로 전면 재작성
- apps/node/internal/node/node.go: New()에서 *config.NodeConfig → nodeID string, OnCapabilityRequest 삭제
- apps/node/internal/adapters/factory.go 신규: BuildFromPayload(proto payload → Registry 동적 초기화)
- apps/node/internal/bootstrap/module.go: OnStart 내부 순차 초기화(dial→config수신→adapter빌드→node생성→handler설정), storeDSN, OnStop에서 store.Close()
- 테스트: edge/node integration test 갱신, factory_test, store_test, server_test(CLIArgsRoundtrip) 신규
잔여 Nit: 없음

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,263 @@
<!-- task=node_centralized_mgmt plan=1 tag=REVIEW_REFACTOR -->
# CLI 어댑터 config 직렬화 버그 수정 + alias 중복 검사 추가
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료하면 즉시 체크 표시한다. 중간 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여 넣는다.
## 배경
이전 구현(plan=0)에서 `buildConfigPayload`가 CLI 어댑터의 `args`/`env` 필드를 `[]string` 타입으로 `structpb.NewStruct`에 전달한다. `structpb.NewValue`는 `[]interface{}`만 처리하므로 `[]string`은 `proto: invalid type: []string` 오류를 반환하고, 이 오류를 `_`로 무시해 CLI 설정 전체가 소실된다. 추가로 `LoadFromConfig`가 중복 alias를 검사하지 않아 동일 alias 등록 시 node ID가 충돌하고 한 노드가 silent하게 덮어써진다.
---
### [REVIEW_REFACTOR-1] buildConfigPayload: []string → structpb 오류 수정
#### 문제
`apps/edge/internal/transport/server.go:160-161`:
```go
profiles[name] = map[string]any{
"command": p.Command,
"args": p.Args, // []string — structpb 오류 발생
"env": p.Env, // []string — structpb 오류 발생
}
```
`p.Args`와 `p.Env`는 `[]string`이다. `structpb.NewValue`는 `[]interface{}`만 처리하고 `[]string`을 받으면 `proto: invalid type: []string` 오류를 반환한다.
`st, _ := structpb.NewStruct(settings)` 로 오류를 무시하면 `st == nil`이 되어 CLI 어댑터 설정 전체가 소실되고, 노드에서 CLI 어댑터가 빈 profile 맵으로 초기화된다.
#### 해결 방법
**`apps/edge/internal/transport/server.go` 변경:**
1. `buildConfigPayload`의 시그니처를 오류 반환형으로 변경.
2. CLI 프로필에서 `[]string` → `[]interface{}` 변환 헬퍼 추가.
3. `addAdapter`를 오류 반환형으로 변경해 `structpb.NewStruct` 오류를 전파.
4. `onNodeConnected` 내 `buildConfigPayload` 호출 시 오류 처리.
Before (`buildConfigPayload`, L130-167):
```go
func buildConfigPayload(rec *edgenode.NodeRecord) *iop.NodeConfigPayload {
payload := &iop.NodeConfigPayload{ ... }
addAdapter := func(typ string, enabled bool, settings map[string]any) {
if !enabled { return }
st, _ := structpb.NewStruct(settings)
payload.Adapters = append(...)
}
// ...
if rec.Adapters.CLI.Enabled {
profiles := make(map[string]any)
for name, p := range rec.Adapters.CLI.Profiles {
profiles[name] = map[string]any{
"command": p.Command,
"args": p.Args,
"env": p.Env,
}
}
addAdapter("cli", true, map[string]any{"profiles": profiles})
}
return payload
}
```
After:
```go
func buildConfigPayload(rec *edgenode.NodeRecord) (*iop.NodeConfigPayload, error) {
payload := &iop.NodeConfigPayload{
Runtime: &iop.NodeRuntimeConfig{
Concurrency: int32(rec.Runtime.Concurrency),
WorkspaceRoot: rec.Runtime.WorkspaceRoot,
},
}
addAdapter := func(typ string, enabled bool, settings map[string]any) error {
if !enabled {
return nil
}
st, err := structpb.NewStruct(settings)
if err != nil {
return fmt.Errorf("buildConfigPayload: %s: %w", typ, err)
}
payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{
Type: typ,
Enabled: true,
Settings: st,
})
return nil
}
if err := addAdapter("mock", true, nil); err != nil {
return nil, err
}
if err := addAdapter("ollama", rec.Adapters.Ollama.Enabled, map[string]any{
"base_url": rec.Adapters.Ollama.BaseURL,
}); err != nil {
return nil, err
}
if err := addAdapter("vllm", rec.Adapters.Vllm.Enabled, map[string]any{
"endpoint": rec.Adapters.Vllm.Endpoint,
}); err != nil {
return nil, err
}
if rec.Adapters.CLI.Enabled {
profiles := make(map[string]any)
for name, p := range rec.Adapters.CLI.Profiles {
profiles[name] = map[string]any{
"command": p.Command,
"args": stringsToAny(p.Args),
"env": stringsToAny(p.Env),
}
}
if err := addAdapter("cli", true, map[string]any{"profiles": profiles}); err != nil {
return nil, err
}
}
return payload, nil
}
func stringsToAny(ss []string) []interface{} {
out := make([]interface{}, len(ss))
for i, s := range ss {
out[i] = s
}
return out
}
```
`onNodeConnected` 내 호출부 변경 (L113-118):
Before:
```go
return &iop.RegisterResponse{
Accepted: true,
NodeId: rec.ID,
Alias: rec.Alias,
Config: buildConfigPayload(rec),
}, nil
```
After:
```go
cfg, err := buildConfigPayload(rec)
if err != nil {
s.logger.Error("build config payload failed",
zap.String("node_id", rec.ID), zap.Error(err))
return &iop.RegisterResponse{Accepted: false, Reason: "internal config error"}, nil
}
return &iop.RegisterResponse{
Accepted: true,
NodeId: rec.ID,
Alias: rec.Alias,
Config: cfg,
}, nil
```
필요한 import 추가: `"fmt"` (buildConfigPayload 오류 포맷용)
#### 수정 파일 및 체크리스트
- [x] `apps/edge/internal/transport/server.go` — `buildConfigPayload` 시그니처 변경, `addAdapter` 오류 반환, `stringsToAny` 추가, `onNodeConnected` 호출부 오류 처리
#### 테스트 작성
신규: `apps/edge/internal/transport/server_test.go` — `TestBuildConfigPayload_CLIArgsRoundtrip`:
- `NodeRecord`에 CLI adapter enabled, profile에 `args: ["--foo", "--bar"]`, `env: ["KEY=val"]` 설정
- `buildConfigPayload(rec)` 호출
- 오류 없이 반환되는지 확인
- 반환된 payload의 CLI AdapterConfig Settings에서 args, env 값이 올바르게 저장되는지 확인
#### 중간 검증
```bash
go build ./apps/edge/internal/transport/...
go test ./apps/edge/internal/transport/...
```
---
### [REVIEW_REFACTOR-2] LoadFromConfig: 중복 alias 검사 추가
#### 문제
`apps/edge/internal/node/store.go:LoadFromConfig`가 중복 token은 검사하지만 중복 alias는 검사하지 않는다.
Alias가 동일하면 `"node-" + alias`로 파생되는 ID가 충돌하고, `byID` 맵에서 후자가 전자를 덮어쓴다.
Before (L66-86):
```go
func LoadFromConfig(defs []config.NodeDefinition) (*NodeStore, error) {
s := NewNodeStore()
seen := make(map[string]bool)
for i, d := range defs {
if d.Token == "" {
return nil, fmt.Errorf("node[%d] alias=%q: token must not be empty", i, d.Alias)
}
if seen[d.Token] {
return nil, fmt.Errorf("node[%d] alias=%q: duplicate token", i, d.Alias)
}
seen[d.Token] = true
// ...
}
return s, nil
}
```
#### 해결 방법
After:
```go
func LoadFromConfig(defs []config.NodeDefinition) (*NodeStore, error) {
s := NewNodeStore()
seenToken := make(map[string]bool)
seenAlias := make(map[string]bool)
for i, d := range defs {
if d.Token == "" {
return nil, fmt.Errorf("node[%d] alias=%q: token must not be empty", i, d.Alias)
}
if seenToken[d.Token] {
return nil, fmt.Errorf("node[%d] alias=%q: duplicate token", i, d.Alias)
}
if seenAlias[d.Alias] {
return nil, fmt.Errorf("node[%d] alias=%q: duplicate alias", i, d.Alias)
}
seenToken[d.Token] = true
seenAlias[d.Alias] = true
// ...
}
return s, nil
}
```
#### 수정 파일 및 체크리스트
- [x] `apps/edge/internal/node/store.go` — `LoadFromConfig`에 alias 중복 검사 추가
#### 테스트 작성
기존 `apps/edge/internal/node/store_test.go`에 케이스 추가:
- `TestLoadFromConfig_DuplicateAlias`: alias 동일, token 다른 두 정의 → 오류 반환
#### 중간 검증
```bash
go test ./apps/edge/internal/node/...
```
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/transport/server.go` | REVIEW_REFACTOR-1 |
| `apps/edge/internal/transport/server_test.go` (신규) | REVIEW_REFACTOR-1 |
| `apps/edge/internal/node/store.go` | REVIEW_REFACTOR-2 |
| `apps/edge/internal/node/store_test.go` | REVIEW_REFACTOR-2 |
## 최종 검증
```bash
go build ./...
go test ./...
```
모든 테스트 PASS, 컴파일 오류 없음이 기대 결과이다.

View file

@ -3,8 +3,38 @@
외부 클라이언트에 OpenAI-compatible HTTP API를 노출하고,
IOP 내부 TCP 프로토콜을 통해 node로 요청을 라우팅한다.
**현재 상태: Placeholder**
node 구현이 안정화된 이후 구현 예정.
**현재 상태: 초기 구현**
node 등록/레지스트리/transport와 edge 콘솔 기반 수동 통신 테스트가 구현되어 있다.
## 원격 edge-node 수동 테스트
`bin/edge.sh`는 edge 서버를 열고 입력 프롬프트를 제공한다.
`bin/node.sh`는 원격 edge 주소로 접속해 등록한 뒤, edge에서 보낸 `RunRequest`를 mock adapter로 실행한다.
테스트 파라미터는 `configs/edge.yaml`, `configs/node.yaml`에 하드코딩한다.
실행 순서:
1. edge 호스트에서 `configs/edge.yaml``server.listen`, `nodes[].token`, `console.adapter`, `console.model`을 확인한다.
2. node 호스트에서 `configs/node.yaml``transport.edge_addr`를 edge 호스트 주소로, `transport.token`을 edge token과 같게 맞춘다.
3. edge 호스트의 `9090/tcp` 포트를 node 호스트에서 접근 가능하게 연다.
4. edge 호스트에서 `./bin/edge.sh`
5. node 호스트에서 `./bin/node.sh`
6. edge 콘솔에서 `/nodes`로 node 등록을 확인한다.
7. edge 콘솔의 `edge>` 프롬프트에 메시지를 입력한다.
8. `[node-{alias}-event]` 라인으로 실행 상태를, `[node-{alias}-message]` 라인으로 실제 응답을 확인한다.
9. edge 콘솔에서 `/exit` 또는 `quit`로 종료한다.
예상 출력:
```text
edge> hello
[edge] sent run_id=manual-... node=local-node adapter=mock model=mock-echo
[node-local-node-event] start run_id=manual-...
[node-local-node-event] complete run_id=manual-... detail="mock execution complete"
[node-local-node-message] echo: hello
```
프롬프트 없이 TCP/protobuf edge 서버만 실행하려면 기존처럼 `go run ./apps/edge/cmd/edge serve -c configs/edge.yaml`를 사용한다.
## 계획된 기능

View file

@ -1,14 +1,25 @@
package main
import (
"bufio"
"context"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"go.uber.org/fx"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/structpb"
"iop/apps/edge/internal/bootstrap"
edgenode "iop/apps/edge/internal/node"
"iop/apps/edge/internal/transport"
"iop/packages/config"
"iop/packages/observability"
iop "iop/proto/gen/iop"
)
var cfgFile string
@ -26,7 +37,7 @@ func rootCmd() *cobra.Command {
Short: "IOP Edge — node gateway server",
}
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "configs/edge.yaml", "config file path")
root.AddCommand(serveCmd())
root.AddCommand(serveCmd(), consoleCmd())
return root
}
@ -44,3 +55,167 @@ func serveCmd() *cobra.Command {
},
}
}
func consoleCmd() *cobra.Command {
return &cobra.Command{
Use: "console",
Short: "Start the edge server with an interactive node console",
RunE: func(_ *cobra.Command, _ []string) error {
cfg, err := config.LoadEdge(cfgFile)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
return runConsole(context.Background(), cfg, os.Stdin, os.Stdout)
},
}
}
func runConsole(ctx context.Context, cfg *config.EdgeConfig, in io.Reader, out io.Writer) error {
logger, err := observability.NewLogger(cfg.Logging.Level, cfg.Logging.Pretty)
if err != nil {
return err
}
defer func() { _ = logger.Sync() }()
registry := edgenode.NewRegistry()
nodeStore, err := edgenode.LoadFromConfig(cfg.Nodes)
if err != nil {
return fmt.Errorf("edge: seed node store: %w", err)
}
server, err := transport.NewServer(cfg.Server.Listen, registry, nodeStore, logger)
if err != nil {
return err
}
events := make(chan *iop.RunEvent, 256)
server.SetRunEventHandler(func(event *iop.RunEvent) {
select {
case events <- event:
default:
logger.Warn("run event dropped", zap.String("run_id", event.GetRunId()))
}
})
if err := server.Start(ctx); err != nil {
return err
}
defer func() { _ = server.Stop() }()
go func() {
if err := observability.ServeMetrics(cfg.Metrics.Port); err != nil {
logger.Warn("metrics server exited", zap.Error(err))
}
}()
fmt.Fprintf(out, "IOP Edge console listening on %s\n", cfg.Server.Listen)
fmt.Fprintf(out, "Console target adapter=%s model=%s\n", cfg.Console.Adapter, cfg.Console.Model)
fmt.Fprintln(out, "Start node.sh on another host, then type a message here.")
fmt.Fprintln(out, "Commands: /nodes, /exit")
scanner := bufio.NewScanner(in)
for {
fmt.Fprint(out, "edge> ")
if !scanner.Scan() {
fmt.Fprintln(out)
return scanner.Err()
}
message := strings.TrimSpace(scanner.Text())
switch strings.ToLower(message) {
case "":
continue
case "/exit", "/quit", "exit", "quit":
fmt.Fprintln(out, "bye")
return nil
case "/nodes":
printNodes(out, registry)
default:
if err := sendConsoleRun(ctx, registry, events, out, cfg.Console.Adapter, cfg.Console.Model, message); err != nil {
fmt.Fprintf(out, "error: %v\n", err)
}
}
}
}
func printNodes(out io.Writer, registry *edgenode.Registry) {
nodes := registry.All()
if len(nodes) == 0 {
fmt.Fprintln(out, "no nodes connected")
return
}
for _, node := range nodes {
fmt.Fprintf(out, "- %s (%s)\n", node.NodeID, node.Alias)
}
}
func sendConsoleRun(ctx context.Context, registry *edgenode.Registry, events <-chan *iop.RunEvent, out io.Writer, adapter, model, message string) error {
entry, err := registry.Pick()
if err != nil {
return err
}
input, err := structpb.NewStruct(map[string]any{"prompt": message})
if err != nil {
return err
}
runID := fmt.Sprintf("manual-%d", time.Now().UnixNano())
req := &iop.RunRequest{
RunId: runID,
Adapter: adapter,
Model: model,
Input: input,
TimeoutSec: 30,
Metadata: map[string]string{
"source": "edge-console",
},
}
nodeAlias := entry.Alias
fmt.Fprintf(out, "[edge] sent run_id=%s node=%s adapter=%s model=%s\n", runID, nodeAlias, adapter, model)
if err := entry.Client.Send(req); err != nil {
return err
}
timer := time.NewTimer(35 * time.Second)
defer timer.Stop()
var response strings.Builder
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return fmt.Errorf("timed out waiting for node response")
case event := <-events:
if event.GetRunId() != runID {
continue
}
switch event.GetType() {
case "start":
fmt.Fprintf(out, "[node-%s-event] start run_id=%s\n", nodeAlias, runID)
case "delta":
response.WriteString(event.GetDelta())
case "complete":
fmt.Fprintf(out, "[node-%s-event] complete run_id=%s detail=%q\n", nodeAlias, runID, event.GetMessage())
printNodeMessage(out, nodeAlias, response.String())
return nil
case "error":
fmt.Fprintf(out, "[node-%s-event] error run_id=%s detail=%q\n", nodeAlias, runID, event.GetError())
printNodeMessage(out, nodeAlias, response.String())
return fmt.Errorf("node reported error")
default:
fmt.Fprintf(out, "[node-%s-event] %s run_id=%s detail=%q\n", nodeAlias, event.GetType(), runID, event.GetMessage())
}
}
}
}
func printNodeMessage(out io.Writer, alias, message string) {
message = strings.TrimSpace(message)
if message == "" {
fmt.Fprintf(out, "[node-%s-message] <empty>\n", alias)
return
}
fmt.Fprintf(out, "[node-%s-message] %s\n", alias, message)
}

View file

@ -2,6 +2,7 @@ package bootstrap
import (
"context"
"fmt"
"go.uber.org/fx"
"go.uber.org/zap"
@ -18,21 +19,32 @@ func Module(cfg *config.EdgeConfig) fx.Option {
func() *config.EdgeConfig { return cfg },
func(cfg *config.EdgeConfig) (*zap.Logger, error) {
return observability.NewLogger(cfg.Logging.Level)
return observability.NewLogger(cfg.Logging.Level, cfg.Logging.Pretty)
},
func() *edgenode.Registry {
return edgenode.NewRegistry()
},
func(cfg *config.EdgeConfig, reg *edgenode.Registry, logger *zap.Logger) (*transport.Server, error) {
return transport.NewServer(cfg.Server.Listen, reg, logger)
func() *edgenode.NodeStore {
return edgenode.NewNodeStore()
},
func(cfg *config.EdgeConfig, reg *edgenode.Registry, ns *edgenode.NodeStore, logger *zap.Logger) (*transport.Server, error) {
return transport.NewServer(cfg.Server.Listen, reg, ns, logger)
},
),
fx.Invoke(func(lc fx.Lifecycle, srv *transport.Server, cfg *config.EdgeConfig, logger *zap.Logger) {
fx.Invoke(func(lc fx.Lifecycle, srv *transport.Server, ns *edgenode.NodeStore, cfg *config.EdgeConfig, logger *zap.Logger) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
seeded, err := edgenode.LoadFromConfig(cfg.Nodes)
if err != nil {
return fmt.Errorf("edge: seed node store: %w", err)
}
for _, rec := range seeded.All() {
ns.Add(rec)
}
if err := srv.Start(ctx); err != nil {
return err
}

View file

@ -5,15 +5,13 @@ import (
"sync"
toki "git.toki-labs.com/toki/common-proto-socket/go"
iop "iop/proto/gen/iop"
)
// NodeEntry represents one connected node.
type NodeEntry struct {
NodeID string
Client *toki.TcpClient
Adapters []*iop.AdapterInfo
NodeID string
Alias string
Client *toki.TcpClient
}
// Registry manages all nodes connected to edge.

View file

@ -6,15 +6,14 @@ import (
toki "git.toki-labs.com/toki/common-proto-socket/go"
edgenode "iop/apps/edge/internal/node"
iop "iop/proto/gen/iop"
)
func TestRegistry_RegisterAndCount(t *testing.T) {
reg := edgenode.NewRegistry()
entry := &edgenode.NodeEntry{
NodeID: "node-001",
Client: &toki.TcpClient{},
Adapters: []*iop.AdapterInfo{{Name: "mock"}},
NodeID: "node-001",
Alias: "local-node",
Client: &toki.TcpClient{},
}
reg.Register(entry)
if reg.Count() != 1 {

View file

@ -0,0 +1,91 @@
package node
import (
"fmt"
"sync"
"iop/packages/config"
)
// NodeRecord is the pre-registered node definition stored in edge.
type NodeRecord struct {
ID string
Alias string
Token string
Adapters config.AdaptersConf
Runtime config.RuntimeConf
}
// NodeStore holds pre-registered node definitions, keyed by token.
type NodeStore struct {
mu sync.RWMutex
byToken map[string]*NodeRecord
byID map[string]*NodeRecord
}
func NewNodeStore() *NodeStore {
return &NodeStore{
byToken: make(map[string]*NodeRecord),
byID: make(map[string]*NodeRecord),
}
}
func (s *NodeStore) Add(rec *NodeRecord) {
s.mu.Lock()
defer s.mu.Unlock()
s.byToken[rec.Token] = rec
s.byID[rec.ID] = rec
}
func (s *NodeStore) FindByToken(token string) (*NodeRecord, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
r, ok := s.byToken[token]
return r, ok
}
func (s *NodeStore) FindByID(id string) (*NodeRecord, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
r, ok := s.byID[id]
return r, ok
}
func (s *NodeStore) All() []*NodeRecord {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*NodeRecord, 0, len(s.byID))
for _, r := range s.byID {
out = append(out, r)
}
return out
}
// LoadFromConfig seeds the store from EdgeConfig.Nodes.
// ID is derived as "node-{alias}" if not provided; use UUIDs in production.
func LoadFromConfig(defs []config.NodeDefinition) (*NodeStore, error) {
s := NewNodeStore()
seenToken := make(map[string]bool)
seenAlias := make(map[string]bool)
for i, d := range defs {
if d.Token == "" {
return nil, fmt.Errorf("node[%d] alias=%q: token must not be empty", i, d.Alias)
}
if seenToken[d.Token] {
return nil, fmt.Errorf("node[%d] alias=%q: duplicate token", i, d.Alias)
}
if seenAlias[d.Alias] {
return nil, fmt.Errorf("node[%d] alias=%q: duplicate alias", i, d.Alias)
}
seenToken[d.Token] = true
seenAlias[d.Alias] = true
s.Add(&NodeRecord{
ID: "node-" + d.Alias,
Alias: d.Alias,
Token: d.Token,
Adapters: d.Adapters,
Runtime: d.Runtime,
})
}
return s, nil
}

View file

@ -0,0 +1,62 @@
package node_test
import (
"testing"
edgenode "iop/apps/edge/internal/node"
"iop/packages/config"
)
func TestLoadFromConfig_Success(t *testing.T) {
store, err := edgenode.LoadFromConfig([]config.NodeDefinition{
{Alias: "alpha", Token: "token-alpha"},
{Alias: "beta", Token: "token-beta"},
})
if err != nil {
t.Fatalf("load from config: %v", err)
}
rec, ok := store.FindByToken("token-alpha")
if !ok {
t.Fatal("expected record for token-alpha")
}
if rec.ID != "node-alpha" {
t.Fatalf("expected id %q, got %q", "node-alpha", rec.ID)
}
if rec.Alias != "alpha" {
t.Fatalf("expected alias %q, got %q", "alpha", rec.Alias)
}
if _, ok := store.FindByToken("missing"); ok {
t.Fatal("expected missing token lookup to fail")
}
}
func TestLoadFromConfig_DuplicateToken(t *testing.T) {
_, err := edgenode.LoadFromConfig([]config.NodeDefinition{
{Alias: "alpha", Token: "token"},
{Alias: "beta", Token: "token"},
})
if err == nil {
t.Fatal("expected duplicate token error")
}
}
func TestLoadFromConfig_DuplicateAlias(t *testing.T) {
_, err := edgenode.LoadFromConfig([]config.NodeDefinition{
{Alias: "alpha", Token: "token-alpha"},
{Alias: "alpha", Token: "token-beta"},
})
if err == nil {
t.Fatal("expected duplicate alias error")
}
}
func TestLoadFromConfig_EmptyToken(t *testing.T) {
_, err := edgenode.LoadFromConfig([]config.NodeDefinition{
{Alias: "alpha"},
})
if err == nil {
t.Fatal("expected empty token error")
}
}

View file

@ -14,6 +14,7 @@ import (
edgenode "iop/apps/edge/internal/node"
"iop/apps/edge/internal/transport"
"iop/packages/config"
iop "iop/proto/gen/iop"
)
@ -51,9 +52,22 @@ func TestEdgeServerIntegration(t *testing.T) {
logger := zap.NewNop()
listenAddr := getFreePort(t)
registry := edgenode.NewRegistry()
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
{
Alias: "test-node",
Token: "test-token",
Adapters: config.AdaptersConf{
Ollama: config.OllamaConf{Enabled: true, BaseURL: "http://localhost:11434"},
},
Runtime: config.RuntimeConf{Concurrency: 2, WorkspaceRoot: "/tmp/iop/test"},
},
})
if err != nil {
t.Fatalf("load node store: %v", err)
}
// 1. Edge 서버 구동
server, err := transport.NewServer(listenAddr, registry, logger)
server, err := transport.NewServer(listenAddr, registry, nodeStore, logger)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
@ -69,8 +83,8 @@ func TestEdgeServerIntegration(t *testing.T) {
// Node parser map (mock)
nodeParser := toki.ParserMap{
toki.TypeNameOf(&iop.CapabilityRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.CapabilityRequest{}
toki.TypeNameOf(&iop.RegisterResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.RegisterResponse{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
@ -85,21 +99,33 @@ func TestEdgeServerIntegration(t *testing.T) {
}
defer client.Close()
// 3. 서버가 CapabilityRequest를 보내오면 CapabilityResponse로 응답
toki.AddRequestListenerTyped[*iop.CapabilityRequest, *iop.CapabilityResponse](
// 3. Node가 RegisterRequest를 보내고 RegisterResponse를 받는다
resp, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator,
func(_ *iop.CapabilityRequest) (*iop.CapabilityResponse, error) {
return &iop.CapabilityResponse{
NodeId: "test-node",
Adapters: []*iop.AdapterInfo{
{Name: "test-adapter", Models: []string{"v1"}, MaxConcurrency: 1},
},
}, nil
},
&iop.RegisterRequest{Token: "test-token"},
2*time.Second,
)
if err != nil {
t.Fatalf("register request: %v", err)
}
if !resp.GetAccepted() {
t.Fatalf("expected accepted register response, got reason %q", resp.GetReason())
}
if resp.GetNodeId() != "node-test-node" {
t.Fatalf("expected node id %q, got %q", "node-test-node", resp.GetNodeId())
}
if resp.GetAlias() != "test-node" {
t.Fatalf("expected alias %q, got %q", "test-node", resp.GetAlias())
}
if resp.GetConfig().GetRuntime().GetConcurrency() != 2 {
t.Fatalf("expected runtime concurrency 2, got %d", resp.GetConfig().GetRuntime().GetConcurrency())
}
if len(resp.GetConfig().GetAdapters()) != 2 {
t.Fatalf("expected mock and ollama adapters, got %d", len(resp.GetConfig().GetAdapters()))
}
// 4. Registry 등록 여부 확인
entry, ok := waitForRegistryEntry(ctx, registry, "test-node")
entry, ok := waitForRegistryEntry(ctx, registry, "node-test-node")
if !ok {
t.Fatal("node was not registered in edge registry within timeout")
}
@ -110,13 +136,10 @@ func TestEdgeServerIntegration(t *testing.T) {
if entry == nil {
t.Fatal("expected registry entry, got nil")
}
if got := entry.NodeID; got != "test-node" {
t.Fatalf("expected node id %q, got %q", "test-node", got)
if got := entry.NodeID; got != "node-test-node" {
t.Fatalf("expected node id %q, got %q", "node-test-node", got)
}
if len(entry.Adapters) != 1 {
t.Fatalf("expected 1 adapter, got %d", len(entry.Adapters))
}
if got := entry.Adapters[0].GetName(); got != "test-adapter" {
t.Fatalf("expected adapter name %q, got %q", "test-adapter", got)
if got := entry.Alias; got != "test-node" {
t.Fatalf("expected alias %q, got %q", "test-node", got)
}
}

View file

@ -2,13 +2,15 @@ package transport
import (
"context"
"fmt"
"net"
"strconv"
"time"
"sync"
toki "git.toki-labs.com/toki/common-proto-socket/go"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
edgenode "iop/apps/edge/internal/node"
iop "iop/proto/gen/iop"
@ -17,7 +19,6 @@ import (
const (
heartbeatIntervalSec = 30
heartbeatWaitSec = 10
capabilityTimeout = 5 * time.Second
)
func edgeParserMap() toki.ParserMap {
@ -26,8 +27,8 @@ func edgeParserMap() toki.ParserMap {
m := &iop.RunEvent{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.CapabilityResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.CapabilityResponse{}
toki.TypeNameOf(&iop.RegisterRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.RegisterRequest{}
return m, proto.Unmarshal(b, m)
},
}
@ -35,13 +36,16 @@ func edgeParserMap() toki.ParserMap {
// Server wraps proto-socket TcpServer and manages node connections.
type Server struct {
tcp *toki.TcpServer
listen string
registry *edgenode.Registry
logger *zap.Logger
tcp *toki.TcpServer
listen string
registry *edgenode.Registry
nodeStore *edgenode.NodeStore
logger *zap.Logger
handlerMu sync.RWMutex
onRunEvent func(*iop.RunEvent)
}
func NewServer(listen string, registry *edgenode.Registry, logger *zap.Logger) (*Server, error) {
func NewServer(listen string, registry *edgenode.Registry, nodeStore *edgenode.NodeStore, logger *zap.Logger) (*Server, error) {
host, portStr, err := net.SplitHostPort(listen)
if err != nil {
return nil, err
@ -51,7 +55,7 @@ func NewServer(listen string, registry *edgenode.Registry, logger *zap.Logger) (
return nil, err
}
s := &Server{listen: listen, registry: registry, logger: logger}
s := &Server{listen: listen, registry: registry, nodeStore: nodeStore, logger: logger}
s.tcp = toki.NewTcpServer(host, port, func(conn net.Conn) *toki.TcpClient {
return toki.NewTcpClient(conn, heartbeatIntervalSec, heartbeatWaitSec, edgeParserMap())
})
@ -71,6 +75,12 @@ func (s *Server) Stop() error {
return s.tcp.Stop()
}
func (s *Server) SetRunEventHandler(handler func(*iop.RunEvent)) {
s.handlerMu.Lock()
s.onRunEvent = handler
s.handlerMu.Unlock()
}
func (s *Server) onNodeConnected(client *toki.TcpClient) {
s.logger.Info("node connection established")
@ -79,34 +89,124 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) {
zap.String("run_id", e.GetRunId()),
zap.String("type", e.GetType()),
)
s.handlerMu.RLock()
handler := s.onRunEvent
s.handlerMu.RUnlock()
if handler != nil {
handler(e)
}
})
go func() {
resp, err := toki.SendRequestTyped[*iop.CapabilityRequest, *iop.CapabilityResponse](
&client.Communicator,
&iop.CapabilityRequest{},
capabilityTimeout,
)
if err != nil {
s.logger.Warn("capability request failed", zap.Error(err))
_ = client.Close()
return
}
toki.AddRequestListenerTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator,
func(req *iop.RegisterRequest) (*iop.RegisterResponse, error) {
rec, ok := s.nodeStore.FindByToken(req.GetToken())
if !ok {
s.logger.Warn("unknown token", zap.String("token_prefix", safePrefix(req.GetToken())))
return &iop.RegisterResponse{Accepted: false, Reason: "unknown token"}, nil
}
entry := &edgenode.NodeEntry{
NodeID: resp.GetNodeId(),
Client: client,
Adapters: resp.GetAdapters(),
}
client.AddDisconnectListener(func(_ *toki.TcpClient) {
s.registry.Unregister(entry.NodeID)
s.logger.Info("node unregistered", zap.String("node_id", entry.NodeID))
})
if _, ok := s.registry.Get(rec.ID); ok {
s.logger.Warn("node already connected", zap.String("node_id", rec.ID))
return &iop.RegisterResponse{Accepted: false, Reason: "node already connected"}, nil
}
s.registry.Register(entry)
s.logger.Info("node registered",
zap.String("node_id", entry.NodeID),
zap.Int("adapters", len(entry.Adapters)),
)
}()
cfg, err := buildConfigPayload(rec)
if err != nil {
s.logger.Error("build config payload failed",
zap.String("node_id", rec.ID),
zap.Error(err),
)
return &iop.RegisterResponse{Accepted: false, Reason: "internal config error"}, nil
}
entry := &edgenode.NodeEntry{
NodeID: rec.ID,
Alias: rec.Alias,
Client: client,
}
client.AddDisconnectListener(func(_ *toki.TcpClient) {
s.registry.Unregister(rec.ID)
s.logger.Info("node unregistered", zap.String("node_id", rec.ID))
})
s.registry.Register(entry)
s.logger.Info("node registered",
zap.String("node_id", rec.ID),
zap.String("alias", rec.Alias),
)
return &iop.RegisterResponse{
Accepted: true,
NodeId: rec.ID,
Alias: rec.Alias,
Config: cfg,
}, nil
},
)
}
func safePrefix(s string) string {
if len(s) > 8 {
return s[:8] + "..."
}
return s
}
func buildConfigPayload(rec *edgenode.NodeRecord) (*iop.NodeConfigPayload, error) {
payload := &iop.NodeConfigPayload{
Runtime: &iop.NodeRuntimeConfig{
Concurrency: int32(rec.Runtime.Concurrency),
WorkspaceRoot: rec.Runtime.WorkspaceRoot,
},
}
addAdapter := func(typ string, enabled bool, settings map[string]any) error {
if !enabled {
return nil
}
st, err := structpb.NewStruct(settings)
if err != nil {
return fmt.Errorf("buildConfigPayload: %s: %w", typ, err)
}
payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{
Type: typ,
Enabled: true,
Settings: st,
})
return nil
}
if err := addAdapter("mock", true, nil); err != nil {
return nil, err
}
if err := addAdapter("ollama", rec.Adapters.Ollama.Enabled, map[string]any{
"base_url": rec.Adapters.Ollama.BaseURL,
}); err != nil {
return nil, err
}
if err := addAdapter("vllm", rec.Adapters.Vllm.Enabled, map[string]any{
"endpoint": rec.Adapters.Vllm.Endpoint,
}); err != nil {
return nil, err
}
if rec.Adapters.CLI.Enabled {
profiles := make(map[string]any)
for name, p := range rec.Adapters.CLI.Profiles {
profiles[name] = map[string]any{
"command": p.Command,
"args": stringsToAny(p.Args),
"env": stringsToAny(p.Env),
}
}
if err := addAdapter("cli", true, map[string]any{"profiles": profiles}); err != nil {
return nil, err
}
}
return payload, nil
}
func stringsToAny(ss []string) []interface{} {
out := make([]interface{}, len(ss))
for i, s := range ss {
out[i] = s
}
return out
}

View file

@ -0,0 +1,71 @@
package transport
import (
"testing"
edgenode "iop/apps/edge/internal/node"
"iop/packages/config"
)
func TestBuildConfigPayload_CLIArgsRoundtrip(t *testing.T) {
rec := &edgenode.NodeRecord{
ID: "node-local",
Alias: "local",
Adapters: config.AdaptersConf{
CLI: config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"codex": {
Command: "codex",
Args: []string{"--foo", "--bar"},
Env: []string{"KEY=val"},
},
},
},
},
}
payload, err := buildConfigPayload(rec)
if err != nil {
t.Fatalf("build config payload: %v", err)
}
var cliSettings map[string]any
for _, adapter := range payload.GetAdapters() {
if adapter.GetType() == "cli" {
cliSettings = adapter.GetSettings().AsMap()
break
}
}
if cliSettings == nil {
t.Fatal("expected cli adapter settings")
}
profiles, ok := cliSettings["profiles"].(map[string]any)
if !ok {
t.Fatalf("expected profiles map, got %T", cliSettings["profiles"])
}
profile, ok := profiles["codex"].(map[string]any)
if !ok {
t.Fatalf("expected codex profile map, got %T", profiles["codex"])
}
if got := profile["command"]; got != "codex" {
t.Fatalf("expected command %q, got %v", "codex", got)
}
args, ok := profile["args"].([]any)
if !ok {
t.Fatalf("expected args []any, got %T", profile["args"])
}
if len(args) != 2 || args[0] != "--foo" || args[1] != "--bar" {
t.Fatalf("unexpected args: %#v", args)
}
env, ok := profile["env"].([]any)
if !ok {
t.Fatalf("expected env []any, got %T", profile["env"])
}
if len(env) != 1 || env[0] != "KEY=val" {
t.Fatalf("unexpected env: %#v", env)
}
}

View file

@ -37,6 +37,21 @@ go build -o bin/node ./apps/node/cmd/node
./bin/node serve --config configs/node.yaml
```
원격 edge에 붙는 수동 테스트는 `configs/node.yaml``transport.edge_addr``transport.token`을 먼저 맞춘 뒤 repo root에서 실행한다.
```bash
./bin/node.sh
```
edge에서 실행 요청을 받으면 node 콘솔에는 받은 입력과 node가 돌려보내는 이벤트/응답이 분리되어 표시된다.
```text
[edge-message] hello
[node-event] start run_id=manual-...
[node-event] complete run_id=manual-... detail="mock execution complete"
[node-message] echo: hello
```
## 어댑터
| 어댑터 | 설명 | 상태 |

View file

@ -0,0 +1,103 @@
package adapters
import (
"fmt"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/structpb"
"iop/apps/node/internal/adapters/cli"
"iop/apps/node/internal/adapters/mock"
"iop/apps/node/internal/adapters/ollama"
"iop/apps/node/internal/adapters/vllm"
"iop/packages/config"
iop "iop/proto/gen/iop"
)
// BuildFromPayload creates a Registry from a NodeConfigPayload received from edge.
func BuildFromPayload(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Registry, error) {
reg := NewRegistry()
reg.Register(mock.New(logger))
for _, ac := range payload.GetAdapters() {
if !ac.GetEnabled() {
continue
}
switch ac.GetType() {
case "mock":
// mock is always registered above.
case "ollama":
cfg := ollamaConfFromStruct(ac.GetSettings())
reg.Register(ollama.New(cfg, logger))
case "vllm":
cfg := vllmConfFromStruct(ac.GetSettings())
reg.Register(vllm.New(cfg, logger))
case "cli":
cfg := cliConfFromStruct(ac.GetSettings())
reg.Register(cli.New(cfg, logger))
default:
return nil, fmt.Errorf("adapters: unknown adapter type %q", ac.GetType())
}
}
return reg, nil
}
func ollamaConfFromStruct(s *structpb.Struct) config.OllamaConf {
if s == nil {
return config.OllamaConf{}
}
m := s.AsMap()
cfg := config.OllamaConf{Enabled: true}
if v, ok := m["base_url"].(string); ok {
cfg.BaseURL = v
}
return cfg
}
func vllmConfFromStruct(s *structpb.Struct) config.VllmConf {
if s == nil {
return config.VllmConf{}
}
m := s.AsMap()
cfg := config.VllmConf{Enabled: true}
if v, ok := m["endpoint"].(string); ok {
cfg.Endpoint = v
}
return cfg
}
func cliConfFromStruct(s *structpb.Struct) config.CLIConf {
if s == nil {
return config.CLIConf{}
}
m := s.AsMap()
cfg := config.CLIConf{Enabled: true, Profiles: make(map[string]config.CLIProfileConf)}
if profiles, ok := m["profiles"].(map[string]any); ok {
for name, pAny := range profiles {
p, ok := pAny.(map[string]any)
if !ok {
continue
}
prof := config.CLIProfileConf{}
if cmd, ok := p["command"].(string); ok {
prof.Command = cmd
}
if args, ok := p["args"].([]any); ok {
for _, a := range args {
if s, ok := a.(string); ok {
prof.Args = append(prof.Args, s)
}
}
}
if envs, ok := p["env"].([]any); ok {
for _, e := range envs {
if s, ok := e.(string); ok {
prof.Env = append(prof.Env, s)
}
}
}
cfg.Profiles[name] = prof
}
}
return cfg
}

View file

@ -0,0 +1,59 @@
package adapters_test
import (
"testing"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/structpb"
"iop/apps/node/internal/adapters"
iop "iop/proto/gen/iop"
)
func TestBuildFromPayload_MockAlwaysPresent(t *testing.T) {
reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{}, zap.NewNop())
if err != nil {
t.Fatalf("build from payload: %v", err)
}
if _, ok := reg.Get("mock"); !ok {
t.Fatal("expected mock adapter to be registered")
}
if got := len(reg.All()); got != 1 {
t.Fatalf("expected 1 adapter, got %d", got)
}
}
func TestBuildFromPayload_OllamaEnabled(t *testing.T) {
settings, err := structpb.NewStruct(map[string]any{
"base_url": "http://localhost:11434",
})
if err != nil {
t.Fatalf("settings: %v", err)
}
reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{Type: "ollama", Enabled: true, Settings: settings},
},
}, zap.NewNop())
if err != nil {
t.Fatalf("build from payload: %v", err)
}
if _, ok := reg.Get("mock"); !ok {
t.Fatal("expected mock adapter to be registered")
}
if _, ok := reg.Get("ollama"); !ok {
t.Fatal("expected ollama adapter to be registered")
}
}
func TestBuildFromPayload_UnknownType(t *testing.T) {
_, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{
Adapters: []*iop.AdapterConfig{
{Type: "unknown", Enabled: true},
},
}, zap.NewNop())
if err == nil {
t.Fatal("expected unknown adapter type error")
}
}

View file

@ -3,18 +3,16 @@ package bootstrap
import (
"context"
"fmt"
"os"
"path/filepath"
"go.uber.org/fx"
"go.uber.org/zap"
"iop/apps/node/internal/adapters"
"iop/apps/node/internal/adapters/cli"
"iop/apps/node/internal/adapters/mock"
"iop/apps/node/internal/adapters/ollama"
"iop/apps/node/internal/adapters/vllm"
"iop/apps/node/internal/node"
"iop/apps/node/internal/router"
"iop/apps/node/internal/runtime"
"iop/apps/node/internal/store"
"iop/apps/node/internal/transport"
"iop/packages/config"
@ -28,55 +26,42 @@ func Module(cfg *config.NodeConfig) fx.Option {
func() *config.NodeConfig { return cfg },
func(cfg *config.NodeConfig) (*zap.Logger, error) {
return observability.NewLogger(cfg.Logging.Level)
},
func(cfg *config.NodeConfig, logger *zap.Logger) (*store.Store, error) {
return store.New(cfg.SQLite.DSN, logger)
},
func(cfg *config.NodeConfig, logger *zap.Logger) *adapters.Registry {
reg := adapters.NewRegistry()
// mock adapter is always registered
reg.Register(mock.New(logger))
if cfg.Adapters.Ollama.Enabled {
reg.Register(ollama.New(cfg.Adapters.Ollama, logger))
}
if cfg.Adapters.Vllm.Enabled {
reg.Register(vllm.New(cfg.Adapters.Vllm, logger))
}
if cfg.Adapters.CLI.Enabled {
reg.Register(cli.New(cfg.Adapters.CLI, logger))
}
return reg
},
func(reg *adapters.Registry, logger *zap.Logger) runtime.Router {
return router.New(reg, logger)
},
func(
cfg *config.NodeConfig,
r runtime.Router,
reg *adapters.Registry,
st *store.Store,
logger *zap.Logger,
) *node.Node {
return node.New(cfg, r, reg, st, logger)
return observability.NewLogger(cfg.Logging.Level, cfg.Logging.Pretty)
},
),
fx.Invoke(func(lc fx.Lifecycle, n *node.Node, cfg *config.NodeConfig, logger *zap.Logger) {
fx.Invoke(func(lc fx.Lifecycle, cfg *config.NodeConfig, logger *zap.Logger) {
var sess *transport.Session
var st *store.Store
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
s, err := transport.DialEdge(ctx, cfg.Transport.EdgeAddr, n, cfg.Node.ID, logger)
result, err := transport.DialEdge(ctx, cfg.Transport.EdgeAddr, cfg.Transport.Token, logger)
if err != nil {
return fmt.Errorf("bootstrap: dial edge: %w", err)
}
reg, err := adapters.BuildFromPayload(result.Config, logger)
if err != nil {
_ = result.Session.Close()
return fmt.Errorf("bootstrap: build adapters: %w", err)
}
dsn, err := storeDSN(result.Config.GetRuntime().GetWorkspaceRoot())
if err != nil {
_ = result.Session.Close()
return err
}
sess = s
st, err = store.New(dsn, logger)
if err != nil {
_ = result.Session.Close()
return fmt.Errorf("bootstrap: store: %w", err)
}
rtr := router.New(reg, logger)
n := node.New(result.NodeID, rtr, reg, st, logger)
result.Session.SetHandler(n)
sess = result.Session
go func() {
if err := observability.ServeMetrics(cfg.Metrics.Port); err != nil {
logger.Warn("metrics server exited", zap.Error(err))
@ -86,7 +71,12 @@ func Module(cfg *config.NodeConfig) fx.Option {
},
OnStop: func(_ context.Context) error {
if sess != nil {
return sess.Close()
if err := sess.Close(); err != nil {
return err
}
}
if st != nil {
return st.Close()
}
return nil
},
@ -94,3 +84,13 @@ func Module(cfg *config.NodeConfig) fx.Option {
}),
)
}
func storeDSN(workspaceRoot string) (string, error) {
if workspaceRoot == "" {
return "file:iop.db?cache=shared&mode=rwc", nil
}
if err := os.MkdirAll(workspaceRoot, 0o755); err != nil {
return "", fmt.Errorf("bootstrap: workspace: %w", err)
}
return "file:" + filepath.Join(workspaceRoot, "iop.db") + "?cache=shared&mode=rwc", nil
}

View file

@ -4,7 +4,11 @@ package node
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"go.uber.org/zap"
@ -14,7 +18,6 @@ import (
"iop/apps/node/internal/runtime"
"iop/apps/node/internal/store"
"iop/apps/node/internal/transport"
"iop/packages/config"
iop "iop/proto/gen/iop"
)
@ -29,14 +32,14 @@ type Node struct {
// New creates a Node. It satisfies transport.Handler.
func New(
cfg *config.NodeConfig,
nodeID string,
router runtime.Router,
registry *adapters.Registry,
st *store.Store,
logger *zap.Logger,
) *Node {
return &Node{
nodeID: cfg.Node.ID,
nodeID: nodeID,
router: router,
registry: registry,
store: st,
@ -62,6 +65,7 @@ func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *i
TimeoutSec: int(req.GetTimeoutSec()),
Metadata: req.GetMetadata(),
}
printEdgeMessage(os.Stdout, rr.Input)
spec, err := n.router.Resolve(ctx, rr)
if err != nil {
@ -92,7 +96,7 @@ func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *i
defer sess.DeregisterCancel(spec.RunID)
}
sink := &sessionSink{sess: sess}
sink := &sessionSink{sess: sess, out: os.Stdout}
execErr := adapter.Execute(execCtx, spec, sink)
status := "completed"
@ -108,24 +112,6 @@ func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *i
return execErr
}
// OnCapabilityRequest returns the node's available adapters.
func (n *Node) OnCapabilityRequest(_ context.Context, _ *transport.Session) (*iop.CapabilityResponse, error) {
resp := &iop.CapabilityResponse{NodeId: n.nodeID}
for _, a := range n.registry.All() {
caps, err := a.Capabilities(context.Background())
if err != nil {
n.logger.Warn("capabilities query failed", zap.String("adapter", a.Name()), zap.Error(err))
continue
}
resp.Adapters = append(resp.Adapters, &iop.AdapterInfo{
Name: caps.AdapterName,
Models: caps.Models,
MaxConcurrency: int32(caps.MaxConcurrency),
})
}
return resp, nil
}
// OnCancel cancels a running execution.
func (n *Node) OnCancel(_ context.Context, sess *transport.Session, req *iop.CancelRequest) error {
n.logger.Info("cancel request", zap.String("run_id", req.GetRunId()))
@ -135,10 +121,13 @@ func (n *Node) OnCancel(_ context.Context, sess *transport.Session, req *iop.Can
// sessionSink wraps a transport.Session to implement runtime.EventSink.
type sessionSink struct {
sess *transport.Session
sess *transport.Session
out io.Writer
response strings.Builder
}
func (s *sessionSink) Emit(_ context.Context, event runtime.RuntimeEvent) error {
s.printEvent(event)
re := &iop.RunEvent{
RunId: event.RunID,
Type: string(event.Type),
@ -157,6 +146,55 @@ func (s *sessionSink) Emit(_ context.Context, event runtime.RuntimeEvent) error
return s.sess.Send(re)
}
func (s *sessionSink) printEvent(event runtime.RuntimeEvent) {
if s.out == nil {
return
}
switch event.Type {
case runtime.EventTypeStart:
fmt.Fprintf(s.out, "[node-event] start run_id=%s\n", event.RunID)
case runtime.EventTypeDelta:
s.response.WriteString(event.Delta)
case runtime.EventTypeComplete:
fmt.Fprintf(s.out, "[node-event] complete run_id=%s detail=%q\n", event.RunID, event.Message)
printTaggedMessage(s.out, "node-message", s.response.String())
case runtime.EventTypeError:
fmt.Fprintf(s.out, "[node-event] error run_id=%s detail=%q\n", event.RunID, event.Error)
printTaggedMessage(s.out, "node-message", s.response.String())
default:
fmt.Fprintf(s.out, "[node-event] %s run_id=%s detail=%q\n", event.Type, event.RunID, event.Message)
}
}
func printEdgeMessage(out io.Writer, input map[string]any) {
if out == nil {
return
}
if input == nil {
printTaggedMessage(out, "edge-message", "")
return
}
if prompt, ok := input["prompt"].(string); ok {
printTaggedMessage(out, "edge-message", prompt)
return
}
b, err := json.MarshalIndent(input, "", " ")
if err != nil {
printTaggedMessage(out, "edge-message", fmt.Sprintf("%v", input))
return
}
fmt.Fprintf(out, "[edge-message]\n%s\n", b)
}
func printTaggedMessage(out io.Writer, tag, message string) {
message = strings.TrimSpace(message)
if message == "" {
fmt.Fprintf(out, "[%s] <empty>\n", tag)
return
}
fmt.Fprintf(out, "[%s] %s\n", tag, message)
}
func structAsMap(s *structpb.Struct) map[string]any {
if s == nil {
return nil

View file

@ -14,7 +14,6 @@ import (
"iop/apps/node/internal/runtime"
"iop/apps/node/internal/store"
"iop/apps/node/internal/transport"
"iop/packages/config"
iop "iop/proto/gen/iop"
)
@ -75,35 +74,7 @@ func makeNode(t *testing.T, rtr runtime.Router, reg *adapters.Registry) (*node.N
}
})
cfg := &config.NodeConfig{Node: config.NodeInfo{ID: "test-node"}}
return node.New(cfg, rtr, reg, st, zap.NewNop()), st
}
func TestOnCapabilityRequest(t *testing.T) {
reg := adapters.NewRegistry()
reg.Register(&countingAdapter{})
n, _ := makeNode(t, &fixedRouter{adapterName: "test"}, reg)
resp, err := n.OnCapabilityRequest(context.Background(), &transport.Session{})
if err != nil {
t.Fatalf("capability request: %v", err)
}
if resp.GetNodeId() != "test-node" {
t.Fatalf("unexpected node ID: %q", resp.GetNodeId())
}
if len(resp.GetAdapters()) != 1 {
t.Fatalf("expected 1 adapter, got %d", len(resp.GetAdapters()))
}
adapter := resp.GetAdapters()[0]
if adapter.GetName() != "test" {
t.Fatalf("unexpected adapter name: %q", adapter.GetName())
}
if len(adapter.GetModels()) != 1 || adapter.GetModels()[0] != "v1" {
t.Fatalf("unexpected models: %v", adapter.GetModels())
}
if adapter.GetMaxConcurrency() != 1 {
t.Fatalf("unexpected max concurrency: %d", adapter.GetMaxConcurrency())
}
return node.New("test-node", rtr, reg, st, zap.NewNop()), st
}
func TestOnRunRequest_RouterError(t *testing.T) {

View file

@ -5,18 +5,31 @@ import (
"fmt"
"net"
"strconv"
"time"
toki "git.toki-labs.com/toki/common-proto-socket/go"
"go.uber.org/zap"
iop "iop/proto/gen/iop"
)
const (
heartbeatIntervalSec = 30
heartbeatWaitSec = 10
registerTimeout = 10 * time.Second
)
// DialEdge connects to the edge TCP server and returns a Session.
func DialEdge(ctx context.Context, addr string, handler Handler, nodeID string, logger *zap.Logger) (*Session, error) {
// RegisterResult is returned by DialEdge after successful registration.
type RegisterResult struct {
Session *Session
NodeID string
Alias string
Config *iop.NodeConfigPayload
}
// DialEdge connects to edge, performs the registration handshake, and returns
// a RegisterResult. Call result.Session.SetHandler after creating node.Node.
func DialEdge(ctx context.Context, addr, token string, logger *zap.Logger) (*RegisterResult, error) {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("transport: invalid addr %q: %w", addr, err)
@ -31,7 +44,29 @@ func DialEdge(ctx context.Context, addr string, handler Handler, nodeID string,
return nil, fmt.Errorf("transport: dial edge %s: %w", addr, err)
}
sess := newSession(client, handler, nodeID, logger)
logger.Info("connected to edge", zap.String("addr", addr), zap.String("node_id", nodeID))
return sess, nil
resp, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator,
&iop.RegisterRequest{Token: token},
registerTimeout,
)
if err != nil {
_ = client.Close()
return nil, fmt.Errorf("transport: register: %w", err)
}
if !resp.GetAccepted() {
_ = client.Close()
return nil, fmt.Errorf("transport: register rejected: %s", resp.GetReason())
}
sess := newSession(client, logger)
logger.Info("registered with edge",
zap.String("node_id", resp.GetNodeId()),
zap.String("alias", resp.GetAlias()),
)
return &RegisterResult{
Session: sess,
NodeID: resp.GetNodeId(),
Alias: resp.GetAlias(),
Config: resp.GetConfig(),
}, nil
}

View file

@ -25,15 +25,6 @@ func (m *mockHandler) OnRunRequest(ctx context.Context, sess *transport.Session,
return sess.Send(&iop.RunEvent{RunId: req.GetRunId(), Type: "test_event"})
}
func (m *mockHandler) OnCapabilityRequest(ctx context.Context, sess *transport.Session) (*iop.CapabilityResponse, error) {
return &iop.CapabilityResponse{
NodeId: "test-node",
Adapters: []*iop.AdapterInfo{
{Name: "test-adapter", Models: []string{"v1"}, MaxConcurrency: 1},
},
}, nil
}
func (m *mockHandler) OnCancel(ctx context.Context, sess *transport.Session, req *iop.CancelRequest) error {
return nil
}
@ -54,8 +45,8 @@ func edgeParserMap() toki.ParserMap {
m := &iop.RunEvent{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.CapabilityResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.CapabilityResponse{}
toki.TypeNameOf(&iop.RegisterRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.RegisterRequest{}
return m, proto.Unmarshal(b, m)
},
}
@ -85,8 +76,23 @@ func TestNodeClientIntegration(t *testing.T) {
// 1. Mock Edge 서버 구동
acceptedCh := make(chan *toki.TcpClient, 1)
registerReqCh := make(chan *iop.RegisterRequest, 1)
server := toki.NewTcpServer(host, port, func(conn net.Conn) *toki.TcpClient {
client := toki.NewTcpClient(conn, 30, 10, edgeParserMap())
toki.AddRequestListenerTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator,
func(req *iop.RegisterRequest) (*iop.RegisterResponse, error) {
registerReqCh <- req
return &iop.RegisterResponse{
Accepted: true,
NodeId: "test-node",
Alias: "test-alias",
Config: &iop.NodeConfigPayload{
Runtime: &iop.NodeRuntimeConfig{Concurrency: 1},
},
}, nil
},
)
acceptedCh <- client
return client
})
@ -100,11 +106,28 @@ func TestNodeClientIntegration(t *testing.T) {
runReqCh: make(chan *iop.RunRequest, 1),
}
sess, err := transport.DialEdge(ctx, listenAddr, handler, "test-node", logger)
result, err := transport.DialEdge(ctx, listenAddr, "test-token", logger)
if err != nil {
t.Fatalf("failed to dial edge: %v", err)
}
defer sess.Close()
defer result.Session.Close()
result.Session.SetHandler(handler)
if result.NodeID != "test-node" {
t.Fatalf("expected node id %q, got %q", "test-node", result.NodeID)
}
if result.Alias != "test-alias" {
t.Fatalf("expected alias %q, got %q", "test-alias", result.Alias)
}
select {
case req := <-registerReqCh:
if req.GetToken() != "test-token" {
t.Fatalf("expected token %q, got %q", "test-token", req.GetToken())
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for register request")
}
edgeClient := waitForAcceptedClient(t, acceptedCh)
runEventCh := make(chan *iop.RunEvent, 1)
@ -112,20 +135,7 @@ func TestNodeClientIntegration(t *testing.T) {
runEventCh <- event
})
// 3. Edge -> Node 로 CapabilityRequest 전송
resp, err := toki.SendRequestTyped[*iop.CapabilityRequest, *iop.CapabilityResponse](
&edgeClient.Communicator,
&iop.CapabilityRequest{},
2*time.Second,
)
if err != nil {
t.Fatalf("failed to send capability request: %v", err)
}
if resp.GetNodeId() != "test-node" {
t.Fatalf("unexpected node id from capability response: %q", resp.GetNodeId())
}
// 4. Edge -> Node 로 RunRequest 전송
// 3. Edge -> Node 로 RunRequest 전송
runReq := &iop.RunRequest{
RunId: "test-run",
Adapter: "test-adapter",
@ -134,7 +144,7 @@ func TestNodeClientIntegration(t *testing.T) {
t.Fatalf("failed to send run request: %v", err)
}
// 5. Node에서 RunRequest 수신 확인
// 4. Node에서 RunRequest 수신 확인
select {
case receivedReq := <-handler.runReqCh:
if receivedReq.GetRunId() != runReq.GetRunId() {

View file

@ -17,8 +17,8 @@ func nodeParserMap() toki.ParserMap {
m := &iop.CancelRequest{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.CapabilityRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.CapabilityRequest{}
toki.TypeNameOf(&iop.RegisterResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.RegisterResponse{}
return m, proto.Unmarshal(b, m)
},
}

View file

@ -59,9 +59,9 @@ func TestNodeParserMap_CancelRequest(t *testing.T) {
}
}
func TestNodeParserMap_CapabilityRequest(t *testing.T) {
func TestNodeParserMap_RegisterResponse(t *testing.T) {
parsers := nodeParserMap()
original := &iop.CapabilityRequest{}
original := &iop.RegisterResponse{Accepted: true, NodeId: "node-1", Alias: "local-node"}
payload, err := proto.Marshal(original)
if err != nil {
t.Fatalf("marshal: %v", err)
@ -77,7 +77,11 @@ func TestNodeParserMap_CapabilityRequest(t *testing.T) {
if err != nil {
t.Fatalf("parse: %v", err)
}
if _, ok := parsed.(*iop.CapabilityRequest); !ok {
t.Fatalf("unexpected capability request type: %T", parsed)
got, ok := parsed.(*iop.RegisterResponse)
if !ok {
t.Fatalf("unexpected register response type: %T", parsed)
}
if !got.GetAccepted() || got.GetNodeId() != original.GetNodeId() || got.GetAlias() != original.GetAlias() {
t.Fatalf("unexpected register response: %+v", got)
}
}

View file

@ -14,24 +14,30 @@ import (
// Handler processes IOP messages received from edge.
type Handler interface {
OnRunRequest(ctx context.Context, sess *Session, req *iop.RunRequest) error
OnCapabilityRequest(ctx context.Context, sess *Session) (*iop.CapabilityResponse, error)
OnCancel(ctx context.Context, sess *Session, req *iop.CancelRequest) error
}
// Session represents the node's persistent connection to edge.
type Session struct {
client *toki.TcpClient
nodeID string
logger *zap.Logger
mu sync.RWMutex
handler Handler
cancelFns sync.Map
}
func newSession(client *toki.TcpClient, handler Handler, nodeID string, logger *zap.Logger) *Session {
s := &Session{client: client, nodeID: nodeID, logger: logger}
func newSession(client *toki.TcpClient, logger *zap.Logger) *Session {
s := &Session{client: client, logger: logger}
toki.AddListenerTyped[*iop.RunRequest](&client.Communicator, func(req *iop.RunRequest) {
go func() {
if err := handler.OnRunRequest(context.Background(), s, req); err != nil {
s.mu.RLock()
h := s.handler
s.mu.RUnlock()
if h == nil {
return
}
if err := h.OnRunRequest(context.Background(), s, req); err != nil {
logger.Warn("run request error",
zap.String("run_id", req.GetRunId()),
zap.Error(err),
@ -40,15 +46,14 @@ func newSession(client *toki.TcpClient, handler Handler, nodeID string, logger *
}()
})
toki.AddRequestListenerTyped[*iop.CapabilityRequest, *iop.CapabilityResponse](
&client.Communicator,
func(_ *iop.CapabilityRequest) (*iop.CapabilityResponse, error) {
return handler.OnCapabilityRequest(context.Background(), s)
},
)
toki.AddListenerTyped[*iop.CancelRequest](&client.Communicator, func(req *iop.CancelRequest) {
if err := handler.OnCancel(context.Background(), s, req); err != nil {
s.mu.RLock()
h := s.handler
s.mu.RUnlock()
if h == nil {
return
}
if err := h.OnCancel(context.Background(), s, req); err != nil {
logger.Warn("cancel error",
zap.String("run_id", req.GetRunId()),
zap.Error(err),
@ -57,12 +62,19 @@ func newSession(client *toki.TcpClient, handler Handler, nodeID string, logger *
})
client.AddDisconnectListener(func(_ *toki.TcpClient) {
logger.Info("disconnected from edge", zap.String("node_id", nodeID))
logger.Info("disconnected from edge")
})
return s
}
// SetHandler attaches the message handler. Called after registration completes.
func (s *Session) SetHandler(h Handler) {
s.mu.Lock()
s.handler = h
s.mu.Unlock()
}
// Send transmits a proto message to edge.
func (s *Session) Send(m proto.Message) error {
return s.client.Send(m)

10
bin/edge.sh Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_FILE="$REPO_ROOT/configs/edge.yaml"
cd "$REPO_ROOT"
echo "[edge] config=$CONFIG_FILE"
exec go run ./apps/edge/cmd/edge console --config "$CONFIG_FILE"

10
bin/node.sh Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_FILE="$REPO_ROOT/configs/node.yaml"
cd "$REPO_ROOT"
echo "[node] config=$CONFIG_FILE"
exec go run ./apps/node/cmd/node serve --config "$CONFIG_FILE"

View file

@ -6,6 +6,32 @@ tls:
logging:
level: "info"
pretty: true
metrics:
port: 9092
console:
adapter: "mock"
model: "mock-echo"
nodes:
- alias: "local-node"
token: "changeme"
adapters:
ollama:
enabled: false
base_url: "http://localhost:11434"
vllm:
enabled: false
endpoint: "http://localhost:8000"
cli:
enabled: false
profiles:
claude:
command: "claude"
args: []
env: []
runtime:
concurrency: 4
workspace_root: "/tmp/iop/workspace"

View file

@ -1,57 +1,10 @@
node:
id: "node-001"
name: "node-local"
transport:
edge_addr: "localhost:9090"
tls:
enabled: false
cert: "certs/node.crt"
key: "certs/node.key"
ca: "certs/ca.crt"
runtime:
concurrency: 4
workspace_root: "/tmp/iop/workspace"
sqlite:
dsn: "file:iop.db?cache=shared&mode=rwc"
token: "changeme"
logging:
level: "info" # debug | info | warn | error
level: "info"
pretty: true
metrics:
port: 9091
adapters:
ollama:
enabled: false
base_url: "http://localhost:11434"
vllm:
enabled: false
endpoint: "http://localhost:8000"
cli:
enabled: false
profiles:
claude:
command: "claude"
args: []
env: []
gemini:
command: "gemini"
args: []
env: []
codex:
command: "codex"
args: []
env: []
opencode:
command: "opencode"
args: []
env: []

View file

@ -54,8 +54,8 @@ Envelope.Payload: JSON-encoded inner message
| `run.event` | node → client | 스트리밍 이벤트 (start/delta/complete/error) |
| `heartbeat` | 양방향 | 연결 유지 (30초 간격) |
| `cancel.request` | client → node | 실행 중단 요청 |
| `capability.request` | client → node | 어댑터 목록 요청 |
| `capability.response` | node → client | 어댑터 정보 응답 |
| `register.request` | node → edge | 사전 발급 token 기반 등록 요청 |
| `register.response` | edge → node | 등록 수락 여부와 중앙 관리 node 설정 응답 |
| `error` | node → client | 에러 응답 |
### mTLS
@ -80,8 +80,7 @@ Hexagonal Architecture
│ │
TCP │ transport.Handler interface │
─────────► OnRunRequest() │
client │ OnCapabilityRequest() │
│ OnCancel() │
client │ OnCancel() │
└───────┬───────────────────────┘
router.Resolve() → ExecutionSpec

View file

@ -5,21 +5,26 @@ import (
)
type NodeConfig struct {
Node NodeInfo `mapstructure:"node" yaml:"node"`
Transport TransportConf `mapstructure:"transport" yaml:"transport"`
TLS TLSConf `mapstructure:"tls" yaml:"tls"`
Runtime RuntimeConf `mapstructure:"runtime" yaml:"runtime"`
SQLite SQLiteConf `mapstructure:"sqlite" yaml:"sqlite"`
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
Adapters AdaptersConf `mapstructure:"adapters" yaml:"adapters"`
}
type EdgeConfig struct {
Server EdgeServerConf `mapstructure:"server" yaml:"server"`
TLS TLSConf `mapstructure:"tls" yaml:"tls"`
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
Server EdgeServerConf `mapstructure:"server" yaml:"server"`
TLS TLSConf `mapstructure:"tls" yaml:"tls"`
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
Console EdgeConsoleConf `mapstructure:"console" yaml:"console"`
Nodes []NodeDefinition `mapstructure:"nodes" yaml:"nodes"`
}
// NodeDefinition is the edge-side record for a pre-registered node.
type NodeDefinition struct {
Alias string `mapstructure:"alias" yaml:"alias"`
Token string `mapstructure:"token" yaml:"token"`
Adapters AdaptersConf `mapstructure:"adapters" yaml:"adapters"`
Runtime RuntimeConf `mapstructure:"runtime" yaml:"runtime"`
}
type NodeInfo struct {
@ -31,8 +36,14 @@ type EdgeServerConf struct {
Listen string `mapstructure:"listen" yaml:"listen"`
}
type EdgeConsoleConf struct {
Adapter string `mapstructure:"adapter" yaml:"adapter"`
Model string `mapstructure:"model" yaml:"model"`
}
type TransportConf struct {
EdgeAddr string `mapstructure:"edge_addr" yaml:"edge_addr"`
Token string `mapstructure:"token" yaml:"token"`
}
type TLSConf struct {
@ -52,7 +63,8 @@ type SQLiteConf struct {
}
type LoggingConf struct {
Level string `mapstructure:"level" yaml:"level"`
Level string `mapstructure:"level" yaml:"level"`
Pretty bool `mapstructure:"pretty" yaml:"pretty"`
}
type MetricsConf struct {
@ -116,12 +128,8 @@ func LoadEdge(cfgFile string) (*EdgeConfig, error) {
func setDefaults(v *viper.Viper) {
v.SetDefault("transport.edge_addr", "localhost:9090")
v.SetDefault("runtime.concurrency", 4)
v.SetDefault("runtime.workspace_root", "/tmp/iop/workspace")
v.SetDefault("sqlite.dsn", "file:iop.db?cache=shared&mode=rwc")
v.SetDefault("logging.level", "info")
v.SetDefault("metrics.port", 9091)
v.SetDefault("tls.enabled", false)
}
func setEdgeDefaults(v *viper.Viper) {
@ -129,4 +137,6 @@ func setEdgeDefaults(v *viper.Viper) {
v.SetDefault("logging.level", "info")
v.SetDefault("metrics.port", 9092)
v.SetDefault("tls.enabled", false)
v.SetDefault("console.adapter", "mock")
v.SetDefault("console.model", "mock-echo")
}

View file

@ -1,8 +1,11 @@
package observability
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/zap"
@ -10,14 +13,47 @@ import (
)
// NewLogger creates a zap production logger at the given level.
func NewLogger(level string) (*zap.Logger, error) {
cfg := zap.NewProductionConfig()
func NewLogger(level string, pretty bool) (*zap.Logger, error) {
lvl, err := zapcore.ParseLevel(level)
if err != nil {
lvl = zapcore.InfoLevel
}
cfg := zap.NewProductionConfig()
cfg.Level = zap.NewAtomicLevelAt(lvl)
return cfg.Build()
if !pretty {
return cfg.Build()
}
encoder := zapcore.NewJSONEncoder(cfg.EncoderConfig)
sink := &prettyJSONWriteSyncer{out: zapcore.Lock(zapcore.AddSync(os.Stderr))}
core := zapcore.NewCore(encoder, sink, cfg.Level)
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)), nil
}
type prettyJSONWriteSyncer struct {
out zapcore.WriteSyncer
}
func (w *prettyJSONWriteSyncer) Write(p []byte) (int, error) {
trimmed := bytes.TrimSpace(p)
if len(trimmed) == 0 {
return len(p), nil
}
var pretty bytes.Buffer
if err := json.Indent(&pretty, trimmed, "", " "); err != nil {
_, writeErr := w.out.Write(p)
return len(p), writeErr
}
pretty.WriteByte('\n')
_, err := w.out.Write(pretty.Bytes())
return len(p), err
}
func (w *prettyJSONWriteSyncer) Sync() error {
return w.out.Sync()
}
// ServeMetrics starts a Prometheus /metrics endpoint on the given port.

View file

@ -419,27 +419,28 @@ func (x *Error) GetMessage() string {
return ""
}
// CapabilityRequest asks the node what adapters/models it supports.
type CapabilityRequest struct {
// RegisterRequest is sent by node to edge immediately on connect.
type RegisterRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CapabilityRequest) Reset() {
*x = CapabilityRequest{}
func (x *RegisterRequest) Reset() {
*x = RegisterRequest{}
mi := &file_proto_iop_runtime_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CapabilityRequest) String() string {
func (x *RegisterRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CapabilityRequest) ProtoMessage() {}
func (*RegisterRequest) ProtoMessage() {}
func (x *CapabilityRequest) ProtoReflect() protoreflect.Message {
func (x *RegisterRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_iop_runtime_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
@ -451,34 +452,44 @@ func (x *CapabilityRequest) ProtoReflect() protoreflect.Message {
return mi.MessageOf(x)
}
// Deprecated: Use CapabilityRequest.ProtoReflect.Descriptor instead.
func (*CapabilityRequest) Descriptor() ([]byte, []int) {
// Deprecated: Use RegisterRequest.ProtoReflect.Descriptor instead.
func (*RegisterRequest) Descriptor() ([]byte, []int) {
return file_proto_iop_runtime_proto_rawDescGZIP(), []int{6}
}
// CapabilityResponse lists the node's available adapters.
type CapabilityResponse struct {
func (x *RegisterRequest) GetToken() string {
if x != nil {
return x.Token
}
return ""
}
// RegisterResponse is sent by edge to node in response to RegisterRequest.
type RegisterResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
NodeId string `protobuf:"bytes,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"`
Adapters []*AdapterInfo `protobuf:"bytes,2,rep,name=adapters,proto3" json:"adapters,omitempty"`
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
NodeId string `protobuf:"bytes,2,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"`
Alias string `protobuf:"bytes,3,opt,name=alias,proto3" json:"alias,omitempty"`
Reason string `protobuf:"bytes,4,opt,name=reason,proto3" json:"reason,omitempty"` // rejection reason
Config *NodeConfigPayload `protobuf:"bytes,5,opt,name=config,proto3" json:"config,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CapabilityResponse) Reset() {
*x = CapabilityResponse{}
func (x *RegisterResponse) Reset() {
*x = RegisterResponse{}
mi := &file_proto_iop_runtime_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CapabilityResponse) String() string {
func (x *RegisterResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CapabilityResponse) ProtoMessage() {}
func (*RegisterResponse) ProtoMessage() {}
func (x *CapabilityResponse) ProtoReflect() protoreflect.Message {
func (x *RegisterResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_iop_runtime_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
@ -490,48 +501,69 @@ func (x *CapabilityResponse) ProtoReflect() protoreflect.Message {
return mi.MessageOf(x)
}
// Deprecated: Use CapabilityResponse.ProtoReflect.Descriptor instead.
func (*CapabilityResponse) Descriptor() ([]byte, []int) {
// Deprecated: Use RegisterResponse.ProtoReflect.Descriptor instead.
func (*RegisterResponse) Descriptor() ([]byte, []int) {
return file_proto_iop_runtime_proto_rawDescGZIP(), []int{7}
}
func (x *CapabilityResponse) GetNodeId() string {
func (x *RegisterResponse) GetAccepted() bool {
if x != nil {
return x.Accepted
}
return false
}
func (x *RegisterResponse) GetNodeId() string {
if x != nil {
return x.NodeId
}
return ""
}
func (x *CapabilityResponse) GetAdapters() []*AdapterInfo {
func (x *RegisterResponse) GetAlias() string {
if x != nil {
return x.Adapters
return x.Alias
}
return ""
}
func (x *RegisterResponse) GetReason() string {
if x != nil {
return x.Reason
}
return ""
}
func (x *RegisterResponse) GetConfig() *NodeConfigPayload {
if x != nil {
return x.Config
}
return nil
}
type AdapterInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Models []string `protobuf:"bytes,2,rep,name=models,proto3" json:"models,omitempty"`
MaxConcurrency int32 `protobuf:"varint,3,opt,name=max_concurrency,json=maxConcurrency,proto3" json:"max_concurrency,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// NodeConfigPayload carries all configuration edge pushes to the node.
type NodeConfigPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
Adapters []*AdapterConfig `protobuf:"bytes,1,rep,name=adapters,proto3" json:"adapters,omitempty"`
Runtime *NodeRuntimeConfig `protobuf:"bytes,2,opt,name=runtime,proto3" json:"runtime,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AdapterInfo) Reset() {
*x = AdapterInfo{}
func (x *NodeConfigPayload) Reset() {
*x = NodeConfigPayload{}
mi := &file_proto_iop_runtime_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AdapterInfo) String() string {
func (x *NodeConfigPayload) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AdapterInfo) ProtoMessage() {}
func (*NodeConfigPayload) ProtoMessage() {}
func (x *AdapterInfo) ProtoReflect() protoreflect.Message {
func (x *NodeConfigPayload) ProtoReflect() protoreflect.Message {
mi := &file_proto_iop_runtime_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
@ -543,32 +575,139 @@ func (x *AdapterInfo) ProtoReflect() protoreflect.Message {
return mi.MessageOf(x)
}
// Deprecated: Use AdapterInfo.ProtoReflect.Descriptor instead.
func (*AdapterInfo) Descriptor() ([]byte, []int) {
// Deprecated: Use NodeConfigPayload.ProtoReflect.Descriptor instead.
func (*NodeConfigPayload) Descriptor() ([]byte, []int) {
return file_proto_iop_runtime_proto_rawDescGZIP(), []int{8}
}
func (x *AdapterInfo) GetName() string {
func (x *NodeConfigPayload) GetAdapters() []*AdapterConfig {
if x != nil {
return x.Name
}
return ""
}
func (x *AdapterInfo) GetModels() []string {
if x != nil {
return x.Models
return x.Adapters
}
return nil
}
func (x *AdapterInfo) GetMaxConcurrency() int32 {
func (x *NodeConfigPayload) GetRuntime() *NodeRuntimeConfig {
if x != nil {
return x.MaxConcurrency
return x.Runtime
}
return nil
}
// AdapterConfig describes one adapter to enable on the node.
type AdapterConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` // "mock" | "ollama" | "vllm" | "cli"
Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"`
Settings *structpb.Struct `protobuf:"bytes,3,opt,name=settings,proto3" json:"settings,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AdapterConfig) Reset() {
*x = AdapterConfig{}
mi := &file_proto_iop_runtime_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AdapterConfig) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AdapterConfig) ProtoMessage() {}
func (x *AdapterConfig) ProtoReflect() protoreflect.Message {
mi := &file_proto_iop_runtime_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AdapterConfig.ProtoReflect.Descriptor instead.
func (*AdapterConfig) Descriptor() ([]byte, []int) {
return file_proto_iop_runtime_proto_rawDescGZIP(), []int{9}
}
func (x *AdapterConfig) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *AdapterConfig) GetEnabled() bool {
if x != nil {
return x.Enabled
}
return false
}
func (x *AdapterConfig) GetSettings() *structpb.Struct {
if x != nil {
return x.Settings
}
return nil
}
// NodeRuntimeConfig is the runtime tuning pushed to the node.
type NodeRuntimeConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
Concurrency int32 `protobuf:"varint,1,opt,name=concurrency,proto3" json:"concurrency,omitempty"`
WorkspaceRoot string `protobuf:"bytes,2,opt,name=workspace_root,json=workspaceRoot,proto3" json:"workspace_root,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NodeRuntimeConfig) Reset() {
*x = NodeRuntimeConfig{}
mi := &file_proto_iop_runtime_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NodeRuntimeConfig) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NodeRuntimeConfig) ProtoMessage() {}
func (x *NodeRuntimeConfig) ProtoReflect() protoreflect.Message {
mi := &file_proto_iop_runtime_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NodeRuntimeConfig.ProtoReflect.Descriptor instead.
func (*NodeRuntimeConfig) Descriptor() ([]byte, []int) {
return file_proto_iop_runtime_proto_rawDescGZIP(), []int{10}
}
func (x *NodeRuntimeConfig) GetConcurrency() int32 {
if x != nil {
return x.Concurrency
}
return 0
}
func (x *NodeRuntimeConfig) GetWorkspaceRoot() string {
if x != nil {
return x.WorkspaceRoot
}
return ""
}
var File_proto_iop_runtime_proto protoreflect.FileDescriptor
const file_proto_iop_runtime_proto_rawDesc = "" +
@ -610,15 +749,25 @@ const file_proto_iop_runtime_proto_rawDesc = "" +
"\x06run_id\x18\x01 \x01(\tR\x05runId\"5\n" +
"\x05Error\x12\x12\n" +
"\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\"\x13\n" +
"\x11CapabilityRequest\"[\n" +
"\x12CapabilityResponse\x12\x17\n" +
"\anode_id\x18\x01 \x01(\tR\x06nodeId\x12,\n" +
"\badapters\x18\x02 \x03(\v2\x10.iop.AdapterInfoR\badapters\"b\n" +
"\vAdapterInfo\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" +
"\x06models\x18\x02 \x03(\tR\x06models\x12'\n" +
"\x0fmax_concurrency\x18\x03 \x01(\x05R\x0emaxConcurrencyB\x13Z\x11iop/proto/gen/iopb\x06proto3"
"\amessage\x18\x02 \x01(\tR\amessage\"'\n" +
"\x0fRegisterRequest\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\"\xa5\x01\n" +
"\x10RegisterResponse\x12\x1a\n" +
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x17\n" +
"\anode_id\x18\x02 \x01(\tR\x06nodeId\x12\x14\n" +
"\x05alias\x18\x03 \x01(\tR\x05alias\x12\x16\n" +
"\x06reason\x18\x04 \x01(\tR\x06reason\x12.\n" +
"\x06config\x18\x05 \x01(\v2\x16.iop.NodeConfigPayloadR\x06config\"u\n" +
"\x11NodeConfigPayload\x12.\n" +
"\badapters\x18\x01 \x03(\v2\x12.iop.AdapterConfigR\badapters\x120\n" +
"\aruntime\x18\x02 \x01(\v2\x16.iop.NodeRuntimeConfigR\aruntime\"r\n" +
"\rAdapterConfig\x12\x12\n" +
"\x04type\x18\x01 \x01(\tR\x04type\x12\x18\n" +
"\aenabled\x18\x02 \x01(\bR\aenabled\x123\n" +
"\bsettings\x18\x03 \x01(\v2\x17.google.protobuf.StructR\bsettings\"\\\n" +
"\x11NodeRuntimeConfig\x12 \n" +
"\vconcurrency\x18\x01 \x01(\x05R\vconcurrency\x12%\n" +
"\x0eworkspace_root\x18\x02 \x01(\tR\rworkspaceRootB\x13Z\x11iop/proto/gen/iopb\x06proto3"
var (
file_proto_iop_runtime_proto_rawDescOnce sync.Once
@ -632,33 +781,38 @@ func file_proto_iop_runtime_proto_rawDescGZIP() []byte {
return file_proto_iop_runtime_proto_rawDescData
}
var file_proto_iop_runtime_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_proto_iop_runtime_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_proto_iop_runtime_proto_goTypes = []any{
(*RunRequest)(nil), // 0: iop.RunRequest
(*RunEvent)(nil), // 1: iop.RunEvent
(*Usage)(nil), // 2: iop.Usage
(*Heartbeat)(nil), // 3: iop.Heartbeat
(*CancelRequest)(nil), // 4: iop.CancelRequest
(*Error)(nil), // 5: iop.Error
(*CapabilityRequest)(nil), // 6: iop.CapabilityRequest
(*CapabilityResponse)(nil), // 7: iop.CapabilityResponse
(*AdapterInfo)(nil), // 8: iop.AdapterInfo
nil, // 9: iop.RunRequest.MetadataEntry
nil, // 10: iop.RunEvent.MetadataEntry
(*structpb.Struct)(nil), // 11: google.protobuf.Struct
(*RunRequest)(nil), // 0: iop.RunRequest
(*RunEvent)(nil), // 1: iop.RunEvent
(*Usage)(nil), // 2: iop.Usage
(*Heartbeat)(nil), // 3: iop.Heartbeat
(*CancelRequest)(nil), // 4: iop.CancelRequest
(*Error)(nil), // 5: iop.Error
(*RegisterRequest)(nil), // 6: iop.RegisterRequest
(*RegisterResponse)(nil), // 7: iop.RegisterResponse
(*NodeConfigPayload)(nil), // 8: iop.NodeConfigPayload
(*AdapterConfig)(nil), // 9: iop.AdapterConfig
(*NodeRuntimeConfig)(nil), // 10: iop.NodeRuntimeConfig
nil, // 11: iop.RunRequest.MetadataEntry
nil, // 12: iop.RunEvent.MetadataEntry
(*structpb.Struct)(nil), // 13: google.protobuf.Struct
}
var file_proto_iop_runtime_proto_depIdxs = []int32{
11, // 0: iop.RunRequest.policy:type_name -> google.protobuf.Struct
11, // 1: iop.RunRequest.input:type_name -> google.protobuf.Struct
9, // 2: iop.RunRequest.metadata:type_name -> iop.RunRequest.MetadataEntry
13, // 0: iop.RunRequest.policy:type_name -> google.protobuf.Struct
13, // 1: iop.RunRequest.input:type_name -> google.protobuf.Struct
11, // 2: iop.RunRequest.metadata:type_name -> iop.RunRequest.MetadataEntry
2, // 3: iop.RunEvent.usage:type_name -> iop.Usage
10, // 4: iop.RunEvent.metadata:type_name -> iop.RunEvent.MetadataEntry
8, // 5: iop.CapabilityResponse.adapters:type_name -> iop.AdapterInfo
6, // [6:6] is the sub-list for method output_type
6, // [6:6] is the sub-list for method input_type
6, // [6:6] is the sub-list for extension type_name
6, // [6:6] is the sub-list for extension extendee
0, // [0:6] is the sub-list for field type_name
12, // 4: iop.RunEvent.metadata:type_name -> iop.RunEvent.MetadataEntry
8, // 5: iop.RegisterResponse.config:type_name -> iop.NodeConfigPayload
9, // 6: iop.NodeConfigPayload.adapters:type_name -> iop.AdapterConfig
10, // 7: iop.NodeConfigPayload.runtime:type_name -> iop.NodeRuntimeConfig
13, // 8: iop.AdapterConfig.settings:type_name -> google.protobuf.Struct
9, // [9:9] is the sub-list for method output_type
9, // [9:9] is the sub-list for method input_type
9, // [9:9] is the sub-list for extension type_name
9, // [9:9] is the sub-list for extension extendee
0, // [0:9] is the sub-list for field type_name
}
func init() { file_proto_iop_runtime_proto_init() }
@ -672,7 +826,7 @@ func file_proto_iop_runtime_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_iop_runtime_proto_rawDesc), len(file_proto_iop_runtime_proto_rawDesc)),
NumEnums: 0,
NumMessages: 11,
NumMessages: 13,
NumExtensions: 0,
NumServices: 0,
},

View file

@ -51,17 +51,35 @@ message Error {
string message = 2;
}
// CapabilityRequest asks the node what adapters/models it supports.
message CapabilityRequest {}
// CapabilityResponse lists the node's available adapters.
message CapabilityResponse {
string node_id = 1;
repeated AdapterInfo adapters = 2;
// RegisterRequest is sent by node to edge immediately on connect.
message RegisterRequest {
string token = 1;
}
message AdapterInfo {
string name = 1;
repeated string models = 2;
int32 max_concurrency = 3;
// RegisterResponse is sent by edge to node in response to RegisterRequest.
message RegisterResponse {
bool accepted = 1;
string node_id = 2;
string alias = 3;
string reason = 4; // rejection reason
NodeConfigPayload config = 5;
}
// NodeConfigPayload carries all configuration edge pushes to the node.
message NodeConfigPayload {
repeated AdapterConfig adapters = 1;
NodeRuntimeConfig runtime = 2;
}
// AdapterConfig describes one adapter to enable on the node.
message AdapterConfig {
string type = 1; // "mock" | "ollama" | "vllm" | "cli"
bool enabled = 2;
google.protobuf.Struct settings = 3;
}
// NodeRuntimeConfig is the runtime tuning pushed to the node.
message NodeRuntimeConfig {
int32 concurrency = 1;
string workspace_root = 2;
}