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:
parent
e6d5254a6e
commit
b4c6550eab
39 changed files with 3385 additions and 398 deletions
2
Makefile
2
Makefile
|
|
@ -28,4 +28,4 @@ proto:
|
|||
proto/iop/job.proto
|
||||
|
||||
clean:
|
||||
rm -rf bin/
|
||||
rm -f bin/node bin/edge bin/control-plane bin/worker
|
||||
|
|
|
|||
|
|
@ -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에 해당 책임을 굳히기 전에 경계를 재검토한다.
|
||||
|
||||
|
|
|
|||
|
|
@ -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는 전달받은 실행 단위 수행에 집중한다.
|
||||
|
||||
|
|
|
|||
|
|
@ -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/` — 설정, 인증, 정책, 메타데이터, 작업, 관측성, 버전 등 공통 패키지이다.
|
||||
|
|
|
|||
264
agent-task/node_centralized_mgmt/code_review_0.log
Normal file
264
agent-task/node_centralized_mgmt/code_review_0.log
Normal 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를 작성한다.
|
||||
126
agent-task/node_centralized_mgmt/code_review_1.log
Normal file
126
agent-task/node_centralized_mgmt/code_review_1.log
Normal 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`를 작성하고 태스크를 종료한다.
|
||||
29
agent-task/node_centralized_mgmt/complete.log
Normal file
29
agent-task/node_centralized_mgmt/complete.log
Normal 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: 없음
|
||||
1281
agent-task/node_centralized_mgmt/plan_0.log
Normal file
1281
agent-task/node_centralized_mgmt/plan_0.log
Normal file
File diff suppressed because it is too large
Load diff
263
agent-task/node_centralized_mgmt/plan_1.log
Normal file
263
agent-task/node_centralized_mgmt/plan_1.log
Normal 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, 컴파일 오류 없음이 기대 결과이다.
|
||||
|
|
@ -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`를 사용한다.
|
||||
|
||||
## 계획된 기능
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
91
apps/edge/internal/node/store.go
Normal file
91
apps/edge/internal/node/store.go
Normal 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
|
||||
}
|
||||
62
apps/edge/internal/node/store_test.go
Normal file
62
apps/edge/internal/node/store_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
71
apps/edge/internal/transport/server_test.go
Normal file
71
apps/edge/internal/transport/server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
## 어댑터
|
||||
|
||||
| 어댑터 | 설명 | 상태 |
|
||||
|
|
|
|||
103
apps/node/internal/adapters/factory.go
Normal file
103
apps/node/internal/adapters/factory.go
Normal 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
|
||||
}
|
||||
59
apps/node/internal/adapters/factory_test.go
Normal file
59
apps/node/internal/adapters/factory_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
10
bin/edge.sh
Executable 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
10
bin/node.sh
Executable 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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue