feat: edge runtime bridge contract & validation, automation phase milestone

This commit is contained in:
toki 2026-06-28 07:23:29 +09:00
parent e66e9fd236
commit 7112edfeef
16 changed files with 545 additions and 32 deletions

View file

@ -36,6 +36,7 @@ Control Plane은 Edge 연결 view와 제어 요청/결과를 관리하고, Edge
- `EdgeHelloRequest.edge_id`: Control Plane connection registry의 Edge identity다. 비어 있으면 hello는 거부된다.
- `EdgeStatusResponse.nodes`: Edge가 소유한 node snapshot view다.
- `EdgeNodeSnapshot.config`: Node에 내려간 config payload의 관찰용 요약이다.
- `EdgeNodeSnapshot.provider_snapshots`: runtime `ProviderSnapshot` wire name을 재사용한 Node resource/provider snapshot이다. `category`가 CLI/API/local inference resource kind를 구분하며, Node address, token, transport internals는 싣지 않는다.
- `EdgeCommandRequest.operation`: Edge-owned operation 이름이다. Node 직접 scheduling 명령으로 사용하지 않는다.
- `EdgeCommandRequest.target_selector`: Edge 내부 operation이 해석할 대상 selector다. Node address나 token을 외부화하지 않는다.
- `metadata`: 필요한 Edge identity 또는 운영 보조 정보만 담는다. secret과 private endpoint 원문은 tracked 계약에 쓰지 않는다.

View file

@ -31,14 +31,16 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c
- `openai.model_routes[]`는 외부 OpenAI-compatible `model` id를 내부 `adapter + target` route로 매핑하는 compatibility catalog다.
- `models[]`는 provider pool 방향의 canonical routing key이며 `nodes[].providers[].id`를 참조한다.
- `nodes[].providers[]`는 Node 아래 resource/provider catalog다. `category``api`, `cli`, `local_inference` resource kind를 나타낸다.
- `nodes[].providers[].id`는 전체 Edge config 안에서 중복되면 안 된다.
- `nodes[].providers[].adapter`는 같은 Node 안의 enabled adapter instance key를 참조해야 한다. Exact instance key를 우선하고, legacy type-name route는 같은 type의 enabled instance가 정확히 하나일 때만 허용한다. `category: cli` resource는 enabled CLI adapter가 필요하다.
- legacy single-instance adapter 설정은 load 시 named instance slice로 normalize된다.
- `NodeConfigPayload`는 Edge가 Node에 내려주는 실행 adapter/runtime payload다.
- refresh 결과는 `applied`, `restart_required`, `rejected`를 구분하고, changed node/provider/model/report slice는 안정적으로 non-nil이어야 한다.
## refresh 분류 기준
- live apply 가능: provider capacity, provider max queue, provider queue timeout, `models[]` display/provider mapping, node runtime concurrency.
- live apply 가능: provider capacity, provider max queue, provider queue timeout, `models[]` display/provider mapping, legacy node runtime concurrency metadata.
- restart required: Edge identity/listen/bootstrap/logging/metrics/console/control-plane/openai/a2a listener config, node 추가/삭제, node token/alias/agent kind, adapter 설정, provider type/category/adapter/models/health/lifecycle capability.
- rejected: candidate config load/validate 실패, invalid refresh mode, apply failure.

View file

@ -49,7 +49,8 @@ Edge는 Node 연결을 수락하고, Node는 연결 직후 등록 요청을 보
- `NodeCommandRequest.type`: 실행이 아닌 조회/제어성 명령이다. adapter execution 요청과 섞지 않는다.
- `NodeConfigPayload.adapters`: Edge가 Node에 내려주는 adapter instance 설정이다.
- `AdapterConfig.name`: node 내부 stable adapter instance identity다. 비어 있으면 legacy single-instance type 이름과 동등하다.
- `NodeRuntimeConfig.concurrency`: node-wide 실행 동시성 제한이다. Node store 위치나 CLI 실행 작업 디렉터리는 이 runtime payload에 싣지 않는다.
- `NodeRuntimeConfig.concurrency`: legacy compatibility runtime metadata다. 실행 admission은 이 값을 node-wide global gate로 사용하지 않고 provider/resource capacity를 기준으로 한다. Node store 위치나 CLI 실행 작업 디렉터리는 이 runtime payload에 싣지 않는다.
- `ProviderSnapshot`: legacy wire name을 유지하지만 Node 아래 resource/provider 상태 snapshot으로 해석한다. `category``api`, `cli`, `local_inference` resource kind를 나타내며, provider-pool dispatch 대상은 Edge config `models[].providers`가 참조한 resource뿐이다.
## 금지 사항

View file

@ -76,6 +76,10 @@ NomadCode가 IOP를 실행 백엔드로 사용할 수 있도록 하는 Responses
- 경로: `agent-roadmap/archive/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- 요약: NomadCode가 IOP CLI를 직접 실행하지 않고 IOP Edge OpenAI-compatible HTTP 호출의 `metadata.workspace`와 task/source metadata만으로 내부 workspace-bound agent target이 해당 checkout에서 산출물을 만들 수 있게 하는 최우선 contract/serving hardening 작업이다.
- [계획] Pi CLI Provider Integration
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/pi-cli-provider-integration.md`
- 요약: Node CLI adapter의 실행 profile 후보에 Pi를 추가하고, Pi JSON stream 출력 파서, config 예시, OpenAI-compatible route smoke를 통해 `adapter=cli,target=pi`를 안정적으로 사용할 수 있게 한다.
- [계획] CLI Agent Group Grade Routing
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/cli-agent-group-grade-routing.md`
- 요약: `PLAN-local-G08.md`, `CODE_REVIEW-cloud-G07.md` 같은 예약어/lane/grade 파일명을 기준으로 CLI provider agent를 목적별 agent group에 라우팅하고, 수동/자동 grade range assignment와 OpenAI-compatible `metadata.agent_group.task_file` 계약을 정리한다.

View file

@ -0,0 +1,88 @@
# Milestone: Pi CLI Provider Integration
## 위치
- Roadmap: `agent-roadmap/ROADMAP.md`
- Phase: `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
## 목표
Node CLI adapter의 provider/profile 후보에 Pi를 추가해 `adapter=cli,target=pi` 실행을 OpenAI-compatible route와 내부 run dispatch에서 안정적으로 사용할 수 있게 한다.
Pi의 JSON streaming 출력은 IOP runtime event로 변환하고, 기본 config 예시는 tools-enabled Pi 실행을 기준으로 둔다.
## 상태
[계획]
## 승격 조건
- 없음
## 구현 잠금
- 상태: 해제
- SDD: 불필요
- SDD 문서: 없음
- SDD 사유: 기존 `CLIProfileConf`, CLI adapter 실행 모델, OpenAI-compatible `adapter + target` route를 재사용하는 provider profile 추가이며 새 wire/API/schema 계약을 만들지 않는다.
- 잠금 해제 조건: 없음
- 결정 필요: 없음
## 범위
- Node CLI adapter가 Pi를 실행 가능한 target profile로 등록하고, 기본 실행은 headless JSON stream 모드를 사용한다.
- Pi profile은 기본적으로 tools-enabled 실행을 유지한다. 스트리밍 회피용 `--no-tools`를 기본 예시나 smoke 기준으로 사용하지 않는다.
- Pi JSON stream의 `message_update`/text delta, completion, error event를 IOP runtime event로 변환한다.
- OpenAI-compatible route 또는 model alias가 `adapter=cli,target=pi`로 연결될 때 workspace-bound prompt가 Pi 실행으로 전달되는지 확인한다.
- Pi 실행에 필요한 로컬 설정 파일과 model/provider 설정은 Pi 자체 설정을 source of truth로 두고, IOP config는 Pi command/profile/route만 소유한다.
## 기능
### Epic: [profile] Pi CLI profile
Pi를 Node CLI adapter의 일반 실행 target으로 등록하고, dev/local config에서 바로 켤 수 있는 예시를 제공한다.
- [ ] [pi-profile] `nodes[].providers[].cli.profiles.pi` 또는 동등한 CLI profile 예시가 `command=pi`, JSON streaming mode, workspace 전달, timeout 기본값을 포함한다. 검증: config roundtrip에서 `target=pi`가 CLI adapter capability로 노출된다.
- [ ] [pi-route] OpenAI-compatible `model_routes` 또는 provider catalog 예시가 `model=pi``adapter=cli,target=pi`로 라우팅하고, CLI target 특성상 `metadata.workspace`가 필요한 경로임을 문서화한다.
- [ ] [tools-enabled] 기본 Pi profile과 smoke 예시는 `--no-tools`를 사용하지 않는다. 검증: tools 포함 요청에서도 첫 text delta가 completion 이전에 관측된다.
### Epic: [stream] Pi JSON stream 변환
Pi headless JSON 출력 이벤트를 IOP runtime event로 변환해 기존 CLI streaming 경로와 같은 의미로 제공한다.
- [ ] [pi-emitter] Pi JSON stream emitter가 text delta, final message, usage/error event를 파싱해 `runtime.RuntimeEvent`로 변환한다. 검증: fake Pi JSON lines fixture에서 delta가 순서대로 emit되고 completion event가 한 번만 발생한다.
- [ ] [pi-error] Pi 프로세스 종료, malformed JSON, provider error, context window exceeded 같은 실패가 route error와 log에 구분되어 남는다. 검증: 각 실패 fixture가 사용자에게 원인 메시지를 보존한 error event로 변환된다.
- [ ] [pi-workspace] workspace-bound 실행에서 Pi 프로세스 cwd와 session/no-session 정책이 profile 설정에 맞게 적용된다. 검증: 상대 workspace 요청이 해당 checkout에서 실행되고, workspace가 필요한 route에서 누락 시 입력 오류를 반환한다.
### Epic: [docs-tests] 문서와 검증
운영자가 Pi CLI provider를 켜고 문제를 재현할 수 있도록 예시와 smoke를 남긴다.
- [ ] [config-docs] Edge/Node 운영 문서와 config sample에 Pi profile, route 예시, 필수 Pi 설정 파일, context window/client 설정 주의사항을 추가한다.
- [ ] [unit-tests] CLI emitter, profile capability, route mapping, workspace-required error에 대한 단위 테스트가 추가된다.
- [ ] [dev-smoke] dev-runtime에서 `adapter=cli,target=pi`로 짧은 OpenAI-compatible streaming smoke를 실행하고, tools-enabled 요청이 completion 전 delta를 반환하는 근거를 남긴다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 기능 Task가 아직 충족되지 않았다.
- 검토 항목:
- [ ] Pi profile이 CLI adapter capability와 config sample에 노출된다
- [ ] Pi JSON stream emitter가 delta/error/completion을 안정적으로 변환한다
- [ ] tools-enabled streaming smoke 근거가 남아 있다
- 리뷰 코멘트: 없음
## 범위 제외
- Pi 자체 provider/model 설정 파일의 소유권 이전 또는 자동 생성. Pi 설정은 Pi가 소유하고 IOP는 실행 profile과 route만 소유한다.
- Pi TUI 화면 렌더링을 IOP terminal bridge로 중계하는 기능.
- Pi 내부 tool 목록, MCP 정책, auth/provider 설정을 IOP schema로 재정의하는 기능.
- CLI Agent Group Grade Routing의 agent group assignment 정책. Pi는 이 Milestone에서 routing 가능한 CLI target으로 준비하고, grade routing 편입은 후속 Milestone에서 다룬다.
## 작업 컨텍스트
- 관련 경로: `apps/node/internal/adapters/cli`, `packages/go/config`, `configs/edge.yaml`, `configs/edge-compose.yaml.tmpl`, `apps/edge/README.md`, `agent-contract/outer/openai-compatible-api.md`
- 표준선(선택): 내부 실행 개념은 기존처럼 `adapter + target`을 유지한다. Pi는 새 top-level adapter가 아니라 `cli` adapter의 target/profile로 추가한다.
- 선행 작업: CLI Automation Runtime 안정화, OpenAI Workspace Agent Execution Contract
- 후속 작업: CLI Agent Group Grade Routing에서 Pi target을 agent group 후보로 포함한다.
- 확인 필요: 없음

View file

@ -32,18 +32,18 @@ task=m-node-resource-model-unification/01_contract_schema, plan=0, tag=API
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Runtime Concurrency Contract | [ ] |
| [API-2] Resource Catalog Validation | [ ] |
| [API-3] Resource Snapshot Compatibility | [ ] |
| [API-1] Runtime Concurrency Contract | [x] |
| [API-2] Resource Catalog Validation | [x] |
| [API-3] Resource Snapshot Compatibility | [x] |
## 구현 체크리스트
- [ ] `NodeRuntimeConfig.concurrency`를 node-wide admission source가 아닌 legacy/compat runtime payload로 계약 문서와 comments에서 정리한다.
- [ ] `nodes[].providers[]`를 현재 wire의 resource catalog로 해석한다는 compat 기준을 문서화하고, `category=cli|api|local_inference`가 CLI/provider/resource 구분자임을 명시한다.
- [ ] `nodes[].providers[].adapter`가 같은 node 안의 enabled adapter instance로 해석되어야 한다는 config validation을 추가한다.
- [ ] provider adapter reference, duplicate/resource category, legacy config compatibility regression test를 추가한다.
- [ ] proto 변경이 발생하면 `make proto`를 실행하고 generated diff가 원본 proto와 일치하는지 확인한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `NodeRuntimeConfig.concurrency`를 node-wide admission source가 아닌 legacy/compat runtime payload로 계약 문서와 comments에서 정리한다.
- [x] `nodes[].providers[]`를 현재 wire의 resource catalog로 해석한다는 compat 기준을 문서화하고, `category=cli|api|local_inference`가 CLI/provider/resource 구분자임을 명시한다.
- [x] `nodes[].providers[].adapter`가 같은 node 안의 enabled adapter instance로 해석되어야 한다는 config validation을 추가한다.
- [x] provider adapter reference, duplicate/resource category, legacy config compatibility regression test를 추가한다.
- [x] proto 변경이 발생하면 `make proto`를 실행하고 generated diff가 원본 proto와 일치하는지 확인한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
@ -59,11 +59,15 @@ task=m-node-resource-model-unification/01_contract_schema, plan=0, tag=API
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- Validation 구현 위치는 계획의 선택지 중 `apps/edge/internal/edgevalidate`로 고정했다. `config.LoadEdge`는 legacy YAML 파싱/구조 검증을 유지하고, `config check`와 refresh candidate loader가 공유하는 runtime validation 단계에서 provider adapter reference를 검증한다.
- 새 validator 때문에 refresh 관련 test YAML fixture의 `nodes[].providers[].adapter`를 명시했다. 이는 새 계약에 맞춘 fixture 정리이며 runtime source behavior 확장은 아니다.
- Proto field 추가/삭제 없이 comment-only 계약 변경으로 처리했고, `make proto` 결과 생성물 comment diff만 발생했다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- Provider/resource adapter reference는 exact enabled instance key를 먼저 인정하고, legacy type-name route는 같은 type의 enabled instance가 정확히 하나일 때만 인정한다.
- `category: cli` resource는 enabled CLI adapter가 있어야 한다. CLI 외 category는 `type`과 adapter type을 강제 매칭하지 않았다. 예: `type: vllm` provider가 `openai_compat` adapter instance를 통해 실행되는 기존 provider 패턴을 보존하기 위해서다.
- `runtime.concurrency``NodeRuntimeConfig.concurrency`는 01번 범위에서 legacy compatibility metadata로 문서화만 했다. Node admission 제거는 후속 `02+01_node_admission` task 범위로 남겼다.
## 사용자 리뷰 요청
@ -91,25 +95,133 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
### API-1 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/configrefresh
(output)
ok iop/apps/edge/internal/configrefresh 0.017s
```
### API-2 중간 검증
```text
$ go test -count=1 ./packages/go/config ./apps/edge/internal/edgevalidate
(output)
ok iop/packages/go/config 0.040s
ok iop/apps/edge/internal/edgevalidate 0.004s
```
### API-3 중간 검증
```text
$ go test -count=1 ./proto/gen/...
(output)
? iop/proto/gen/iop [no test files]
```
```text
$ make proto
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
```
```text
$ git diff -- proto/gen/iop
diff --git a/proto/gen/iop/control.pb.go b/proto/gen/iop/control.pb.go
index 05e4b78..362250f 100644
--- a/proto/gen/iop/control.pb.go
+++ b/proto/gen/iop/control.pb.go
@@ -490,13 +490,15 @@ func (x *EdgeStatusRequest) GetRequestId() string {
// only mirrors what the Edge service exposes and must not carry Node address,
// token, or scheduling fields.
type EdgeNodeSnapshot struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- NodeId string `protobuf:"bytes,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"`
- Alias string `protobuf:"bytes,2,opt,name=alias,proto3" json:"alias,omitempty"`
- Label string `protobuf:"bytes,3,opt,name=label,proto3" json:"label,omitempty"`
- Connected bool `protobuf:"varint,4,opt,name=connected,proto3" json:"connected,omitempty"`
- Config *NodeConfigPayload `protobuf:"bytes,5,opt,name=config,proto3" json:"config,omitempty"`
- ProviderSnapshots []*ProviderSnapshot `protobuf:"bytes,6,rep,name=provider_snapshots,json=providerSnapshots,proto3" json:"provider_snapshots,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ NodeId string `protobuf:"bytes,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"`
+ Alias string `protobuf:"bytes,2,opt,name=alias,proto3" json:"alias,omitempty"`
+ Label string `protobuf:"bytes,3,opt,name=label,proto3" json:"label,omitempty"`
+ Connected bool `protobuf:"varint,4,opt,name=connected,proto3" json:"connected,omitempty"`
+ Config *NodeConfigPayload `protobuf:"bytes,5,opt,name=config,proto3" json:"config,omitempty"`
+ // provider_snapshots uses the runtime ProviderSnapshot wire name for node
+ // resources/providers. category distinguishes CLI/API/local inference.
+ ProviderSnapshots []*ProviderSnapshot `protobuf:"bytes,6,rep,name=provider_snapshots,json=providerSnapshots,proto3" json:"provider_snapshots,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
diff --git a/proto/gen/iop/runtime.pb.go b/proto/gen/iop/runtime.pb.go
index 803a783..bfcfd3d 100644
--- a/proto/gen/iop/runtime.pb.go
+++ b/proto/gen/iop/runtime.pb.go
@@ -970,6 +970,10 @@ func (x *NodeCommandResponse) GetProviderSnapshots() []*ProviderSnapshot {
return nil
}
+// ProviderSnapshot is the legacy wire name for a node resource/provider status
+// snapshot. category identifies whether the resource is API, CLI, or local
+// inference; provider-pool dispatch is limited to catalog resources referenced
+// by models[].providers.
type ProviderSnapshot struct {
state protoimpl.MessageState `protogen:"open.v1"`
Adapter string `protobuf:"bytes,1,opt,name=adapter,proto3" json:"adapter,omitempty"`
@@ -2106,10 +2110,12 @@ func (x *OpenAICompatAdapterConfig) GetRequestTimeoutMs() int32 {
return 0
}
-// NodeRuntimeConfig is the runtime tuning pushed to the node.
+// NodeRuntimeConfig carries legacy node runtime metadata. Execution admission
+// must not use this as a node-wide global gate; provider/resource capacity owns
+// concurrency.
type NodeRuntimeConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
- Concurrency int32 `protobuf:"varint,1,opt,name=concurrency,proto3" json:"concurrency,omitempty"`
+ Concurrency int32 `protobuf:"varint,1,opt,name=concurrency,proto3" json:"concurrency,omitempty"` // legacy compatibility, 0/unset means no node-wide limit
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
```
### 최종 검증
```text
$ go test -count=1 ./packages/go/... ./proto/gen/... ./apps/edge/internal/configrefresh ./apps/edge/internal/node
(output)
ok iop/packages/go/audit 0.002s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.040s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.007s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.010s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
? iop/proto/gen/iop [no test files]
ok iop/apps/edge/internal/configrefresh 0.014s
ok iop/apps/edge/internal/node 0.004s
```
```text
$ go test -count=1 ./apps/edge/...
ok iop/apps/edge/cmd/edge 0.041s
ok iop/apps/edge/internal/bootstrap 0.280s
ok iop/apps/edge/internal/configrefresh 0.025s
ok iop/apps/edge/internal/controlplane 4.450s
ok iop/apps/edge/internal/edgecmd 0.029s
ok iop/apps/edge/internal/edgevalidate 0.015s
ok iop/apps/edge/internal/events 0.013s
ok iop/apps/edge/internal/input 0.013s
ok iop/apps/edge/internal/input/a2a 0.008s
ok iop/apps/edge/internal/node 0.005s
ok iop/apps/edge/internal/openai 1.514s
ok iop/apps/edge/internal/opsconsole 0.006s
ok iop/apps/edge/internal/service 0.439s
ok iop/apps/edge/internal/transport 2.038s
```
```text
$ git diff --check
```
> **[IMPLEMENTING AGENT - BEFORE SAVING] Have you filled in every implementation-owned section?**

View file

@ -220,6 +220,7 @@ nodes:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 2
max_queue: 4
@ -254,6 +255,7 @@ nodes:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 8
max_queue: 4
@ -880,6 +882,7 @@ nodes:
- id: "prov-r"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3"]
capacity: %d
max_queue: 4
@ -1092,6 +1095,7 @@ nodes:
- id: "prov-skip"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3"]
capacity: %d
max_queue: 4
@ -1247,6 +1251,7 @@ nodes:
- id: "prov-nc"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3"]
capacity: 2
max_queue: 4

View file

@ -217,7 +217,8 @@ func Classify(current, candidate *config.EdgeConfig) Result {
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].token", key), StatusRestartRequired, cur.Token, next.Token)
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].agent_kind", key), StatusRestartRequired, cur.AgentKind, next.AgentKind)
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[%q].adapters", key), StatusRestartRequired, cur.Adapters, next.Adapters)
// Node-wide concurrency is live-applyable.
// Legacy runtime concurrency metadata is live-applyable for compat.
// Runtime admission is owned by provider/resource capacity.
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].runtime.concurrency", key), StatusApplied, cur.Runtime.Concurrency, next.Runtime.Concurrency)
}
for key := range candidateNodes {

View file

@ -84,6 +84,7 @@ nodes:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 2
max_queue: 4
@ -109,6 +110,7 @@ nodes:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 8
max_queue: 4
@ -166,6 +168,7 @@ nodes:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 2
max_queue: 4
@ -239,11 +242,13 @@ nodes:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 2
- id: "prov-b"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 2
models:
@ -266,11 +271,13 @@ nodes:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 2
- id: "prov-b"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 2
models:
@ -323,6 +330,7 @@ nodes:
- id: "prov-a"
type: "vllm"
category: "api"
adapter: "cli"
models: ["llama3.1"]
capacity: 2
max_queue: 4
@ -420,6 +428,7 @@ nodes:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 2
max_queue: 4
@ -444,6 +453,7 @@ nodes:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 8
max_queue: 4
@ -540,6 +550,7 @@ nodes:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 2
max_queue: 4
@ -564,6 +575,7 @@ nodes:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 8
max_queue: 4
@ -645,6 +657,7 @@ nodes:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 2
`
@ -662,6 +675,7 @@ nodes:
- id: "prov-a"
type: "ollama"
category: "local_inference"
adapter: "cli"
models: ["llama3.1"]
capacity: 4
`

View file

@ -2,6 +2,7 @@ package edgevalidate
import (
"fmt"
"strings"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config"
@ -60,10 +61,105 @@ func ValidateEdgeConfig(cfg *config.EdgeConfig) error {
if n.Runtime.Concurrency < 0 {
return fmt.Errorf("node %q: runtime concurrency must be non-negative", name)
}
if err := validateProviderAdapterReferences(name, n); err != nil {
return err
}
}
return nil
}
func validateProviderAdapterReferences(nodeName string, n config.NodeDefinition) error {
idx := buildAdapterIndex(n.Adapters)
for i, p := range n.Providers {
adapter := strings.TrimSpace(p.Adapter)
if adapter == "" {
return fmt.Errorf("node %q: providers[%d] %q: adapter must not be empty", nodeName, i, p.ID)
}
if p.Category == config.CategoryCLI && !n.Adapters.CLI.Enabled {
return fmt.Errorf("node %q: providers[%d] %q: category cli requires enabled cli adapter", nodeName, i, p.ID)
}
if idx.exact[adapter] {
continue
}
if count, knownType := idx.typeCount[adapter]; knownType {
switch count {
case 0:
return fmt.Errorf("node %q: providers[%d] %q: adapter %q does not resolve to an enabled adapter instance", nodeName, i, p.ID, adapter)
case 1:
continue
default:
return fmt.Errorf("node %q: providers[%d] %q: adapter %q is ambiguous; use an enabled adapter instance key", nodeName, i, p.ID, adapter)
}
}
return fmt.Errorf("node %q: providers[%d] %q: adapter %q does not resolve to an enabled adapter instance", nodeName, i, p.ID, adapter)
}
return nil
}
type adapterIndex struct {
exact map[string]bool
typeKeys map[string]map[string]struct{}
typeCount map[string]int
}
func buildAdapterIndex(a config.AdaptersConf) adapterIndex {
idx := adapterIndex{
exact: make(map[string]bool),
typeKeys: make(map[string]map[string]struct{}),
typeCount: map[string]int{"ollama": 0, "vllm": 0, "openai_compat": 0, "cli": 0},
}
if a.Ollama.Enabled {
idx.add("ollama", "ollama")
}
for _, inst := range a.OllamaInstances {
if inst.Enabled {
idx.add("ollama", defaultAdapterKey(inst.Name, "ollama"))
}
}
if a.Vllm.Enabled {
idx.add("vllm", "vllm")
}
for _, inst := range a.VllmInstances {
if inst.Enabled {
idx.add("vllm", defaultAdapterKey(inst.Name, "vllm"))
}
}
if a.OpenAICompat.Enabled {
idx.add("openai_compat", "openai_compat")
}
for _, inst := range a.OpenAICompatInstances {
if inst.Enabled {
idx.add("openai_compat", defaultAdapterKey(inst.Name, "openai_compat"))
}
}
if a.CLI.Enabled {
idx.add("cli", "cli")
}
for typeName, keys := range idx.typeKeys {
idx.typeCount[typeName] = len(keys)
}
return idx
}
func (idx adapterIndex) add(typeName, key string) {
key = strings.TrimSpace(key)
if key == "" {
return
}
idx.exact[key] = true
if idx.typeKeys[typeName] == nil {
idx.typeKeys[typeName] = make(map[string]struct{})
}
idx.typeKeys[typeName][key] = struct{}{}
}
func defaultAdapterKey(name, typeName string) string {
if strings.TrimSpace(name) == "" {
return typeName
}
return name
}
func anyOllamaEnabled(a config.AdaptersConf) bool {
if a.Ollama.Enabled {
return true

View file

@ -0,0 +1,167 @@
package edgevalidate
import (
"strings"
"testing"
"iop/packages/go/config"
)
func TestValidateEdgeConfigProviderAdapterReferences(t *testing.T) {
tests := []struct {
name string
node config.NodeDefinition
wantErr string
}{
{
name: "valid exact openai compat instance",
node: config.NodeDefinition{
ID: "node-1",
Token: "tok",
Adapters: config.AdaptersConf{
OpenAICompatInstances: []config.OpenAICompatInstanceConf{
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
},
},
Providers: []config.NodeProviderConf{
{ID: "prov-vllm", Type: "vllm", Category: config.CategoryAPI, Adapter: "vllm-gpu"},
},
},
},
{
name: "valid single type route",
node: config.NodeDefinition{
ID: "node-1",
Token: "tok",
Adapters: config.AdaptersConf{
OpenAICompatInstances: []config.OpenAICompatInstanceConf{
{Name: "vllm-gpu", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
},
},
Providers: []config.NodeProviderConf{
{ID: "prov-vllm", Type: "vllm", Category: config.CategoryAPI, Adapter: "openai_compat"},
},
},
},
{
name: "missing adapter rejected",
node: config.NodeDefinition{
ID: "node-1",
Token: "tok",
Adapters: config.AdaptersConf{
CLI: config.CLIConf{Enabled: true},
},
Providers: []config.NodeProviderConf{
{ID: "prov-vllm", Type: "vllm", Category: config.CategoryAPI, Adapter: "missing"},
},
},
wantErr: "does not resolve",
},
{
name: "disabled instance rejected",
node: config.NodeDefinition{
ID: "node-1",
Token: "tok",
Adapters: config.AdaptersConf{
CLI: config.CLIConf{Enabled: true},
OpenAICompatInstances: []config.OpenAICompatInstanceConf{
{Name: "vllm-gpu", Enabled: false, Endpoint: "http://127.0.0.1:8000/v1"},
},
},
Providers: []config.NodeProviderConf{
{ID: "prov-vllm", Type: "vllm", Category: config.CategoryAPI, Adapter: "vllm-gpu"},
},
},
wantErr: "does not resolve",
},
{
name: "ambiguous type route rejected",
node: config.NodeDefinition{
ID: "node-1",
Token: "tok",
Adapters: config.AdaptersConf{
OpenAICompatInstances: []config.OpenAICompatInstanceConf{
{Name: "vllm-a", Enabled: true, Endpoint: "http://127.0.0.1:8000/v1"},
{Name: "vllm-b", Enabled: true, Endpoint: "http://127.0.0.1:8001/v1"},
},
},
Providers: []config.NodeProviderConf{
{ID: "prov-vllm", Type: "vllm", Category: config.CategoryAPI, Adapter: "openai_compat"},
},
},
wantErr: "ambiguous",
},
{
name: "cli resource requires enabled cli",
node: config.NodeDefinition{
ID: "node-1",
Token: "tok",
Adapters: config.AdaptersConf{
OllamaInstances: []config.OllamaInstanceConf{
{Name: "ollama-local", Enabled: true, BaseURL: "http://127.0.0.1:11434"},
},
},
Providers: []config.NodeProviderConf{
{ID: "cli-codex", Type: "cli", Category: config.CategoryCLI, Adapter: "cli"},
},
},
wantErr: "category cli requires enabled cli adapter",
},
{
name: "valid cli resource",
node: config.NodeDefinition{
ID: "node-1",
Token: "tok",
Adapters: config.AdaptersConf{
CLI: config.CLIConf{Enabled: true},
},
Providers: []config.NodeProviderConf{
{ID: "cli-codex", Type: "cli", Category: config.CategoryCLI, Adapter: "cli"},
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidateEdgeConfig(&config.EdgeConfig{Nodes: []config.NodeDefinition{tc.node}})
if tc.wantErr == "" {
if err != nil {
t.Fatalf("ValidateEdgeConfig: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
}
})
}
}
func TestValidateEdgeConfigProviderAdapterEmptyRejected(t *testing.T) {
cfg := &config.EdgeConfig{
Nodes: []config.NodeDefinition{
{
ID: "node-1",
Token: "tok",
Adapters: config.AdaptersConf{
CLI: config.CLIConf{Enabled: true},
},
Providers: []config.NodeProviderConf{
{ID: "prov-empty", Type: "cli", Category: config.CategoryCLI, Adapter: " "},
},
},
},
}
err := ValidateEdgeConfig(cfg)
if err == nil {
t.Fatal("expected empty adapter error")
}
if !strings.Contains(err.Error(), "adapter must not be empty") {
t.Fatalf("expected adapter empty error, got %v", err)
}
}

View file

@ -109,16 +109,20 @@ func validateCategory(c Category) error {
}
}
// NodeProviderConf defines a provider candidate that belongs to a node.
// NodeProviderConf defines a resource/provider candidate that belongs to a
// node. Edge validation resolves Adapter against enabled adapter instances on
// the same node before this resource can be used for dispatch.
type NodeProviderConf struct {
// ID uniquely identifies this provider within the node and is referenced
// by models[].providers keys.
ID string `mapstructure:"id" yaml:"id"`
// Type is the runtime type (e.g. "ollama", "vllm", "lemonade", "sglang", "openai_api", "cli").
Type string `mapstructure:"type" yaml:"type"`
// Category classifies the provider; MVP values: api, cli, local_inference.
// Category classifies the resource; MVP values: api, cli, local_inference.
Category Category `mapstructure:"category" yaml:"category"`
// Adapter is the concrete adapter instance key used by the edge to dispatch.
// Adapter is the concrete enabled adapter instance key used by the edge to
// dispatch. A legacy type-name route is valid only when exactly one enabled
// adapter instance of that type exists on the same node.
Adapter string `mapstructure:"adapter" yaml:"adapter,omitempty"`
// Models lists the served model names this provider can actually serve.
Models []string `mapstructure:"models" yaml:"models,omitempty"`
@ -328,6 +332,8 @@ type TLSConf struct {
}
type RuntimeConf struct {
// Concurrency is legacy compatibility metadata. Runtime admission must use
// provider/resource capacity rather than treating this as a node-wide gate.
Concurrency int `mapstructure:"concurrency" yaml:"concurrency"`
}

View file

@ -490,13 +490,15 @@ func (x *EdgeStatusRequest) GetRequestId() string {
// only mirrors what the Edge service exposes and must not carry Node address,
// token, or scheduling fields.
type EdgeNodeSnapshot struct {
state protoimpl.MessageState `protogen:"open.v1"`
NodeId string `protobuf:"bytes,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"`
Alias string `protobuf:"bytes,2,opt,name=alias,proto3" json:"alias,omitempty"`
Label string `protobuf:"bytes,3,opt,name=label,proto3" json:"label,omitempty"`
Connected bool `protobuf:"varint,4,opt,name=connected,proto3" json:"connected,omitempty"`
Config *NodeConfigPayload `protobuf:"bytes,5,opt,name=config,proto3" json:"config,omitempty"`
ProviderSnapshots []*ProviderSnapshot `protobuf:"bytes,6,rep,name=provider_snapshots,json=providerSnapshots,proto3" json:"provider_snapshots,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
NodeId string `protobuf:"bytes,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"`
Alias string `protobuf:"bytes,2,opt,name=alias,proto3" json:"alias,omitempty"`
Label string `protobuf:"bytes,3,opt,name=label,proto3" json:"label,omitempty"`
Connected bool `protobuf:"varint,4,opt,name=connected,proto3" json:"connected,omitempty"`
Config *NodeConfigPayload `protobuf:"bytes,5,opt,name=config,proto3" json:"config,omitempty"`
// provider_snapshots uses the runtime ProviderSnapshot wire name for node
// resources/providers. category distinguishes CLI/API/local inference.
ProviderSnapshots []*ProviderSnapshot `protobuf:"bytes,6,rep,name=provider_snapshots,json=providerSnapshots,proto3" json:"provider_snapshots,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}

View file

@ -970,6 +970,10 @@ func (x *NodeCommandResponse) GetProviderSnapshots() []*ProviderSnapshot {
return nil
}
// ProviderSnapshot is the legacy wire name for a node resource/provider status
// snapshot. category identifies whether the resource is API, CLI, or local
// inference; provider-pool dispatch is limited to catalog resources referenced
// by models[].providers.
type ProviderSnapshot struct {
state protoimpl.MessageState `protogen:"open.v1"`
Adapter string `protobuf:"bytes,1,opt,name=adapter,proto3" json:"adapter,omitempty"`
@ -2106,10 +2110,12 @@ func (x *OpenAICompatAdapterConfig) GetRequestTimeoutMs() int32 {
return 0
}
// NodeRuntimeConfig is the runtime tuning pushed to the node.
// NodeRuntimeConfig carries legacy node runtime metadata. Execution admission
// must not use this as a node-wide global gate; provider/resource capacity owns
// concurrency.
type NodeRuntimeConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
Concurrency int32 `protobuf:"varint,1,opt,name=concurrency,proto3" json:"concurrency,omitempty"`
Concurrency int32 `protobuf:"varint,1,opt,name=concurrency,proto3" json:"concurrency,omitempty"` // legacy compatibility, 0/unset means no node-wide limit
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}

View file

@ -80,6 +80,8 @@ message EdgeNodeSnapshot {
string label = 3;
bool connected = 4;
NodeConfigPayload config = 5;
// provider_snapshots uses the runtime ProviderSnapshot wire name for node
// resources/providers. category distinguishes CLI/API/local inference.
repeated ProviderSnapshot provider_snapshots = 6;
}

View file

@ -115,6 +115,10 @@ message NodeCommandResponse {
repeated ProviderSnapshot provider_snapshots = 9;
}
// ProviderSnapshot is the legacy wire name for a node resource/provider status
// snapshot. category identifies whether the resource is API, CLI, or local
// inference; provider-pool dispatch is limited to catalog resources referenced
// by models[].providers.
message ProviderSnapshot {
string adapter = 1;
string status = 2; // unknown|available|unavailable
@ -248,9 +252,11 @@ message OpenAICompatAdapterConfig {
int32 request_timeout_ms = 7;
}
// NodeRuntimeConfig is the runtime tuning pushed to the node.
// NodeRuntimeConfig carries legacy node runtime metadata. Execution admission
// must not use this as a node-wide global gate; provider/resource capacity owns
// concurrency.
message NodeRuntimeConfig {
int32 concurrency = 1;
int32 concurrency = 1; // legacy compatibility, 0/unset means no node-wide limit
reserved 2;
}