From 7112edfeeff0ca456f4a59143707958ec556a9b6 Mon Sep 17 00:00:00 2001 From: toki Date: Sun, 28 Jun 2026 07:23:29 +0900 Subject: [PATCH] feat: edge runtime bridge contract & validation, automation phase milestone --- .../inner/control-plane-edge-wire.md | 1 + .../inner/edge-config-runtime-refresh.md | 4 +- .../inner/edge-node-runtime-wire.md | 3 +- .../phase/automation-runtime-bridge/PHASE.md | 4 + .../milestones/pi-cli-provider-integration.md | 88 +++++++++ .../CODE_REVIEW-cloud-G07.md | 142 +++++++++++++-- apps/edge/internal/bootstrap/runtime_test.go | 5 + apps/edge/internal/configrefresh/classify.go | 3 +- .../internal/configrefresh/classify_test.go | 14 ++ apps/edge/internal/edgevalidate/validate.go | 96 ++++++++++ .../internal/edgevalidate/validate_test.go | 167 ++++++++++++++++++ packages/go/config/config.go | 12 +- proto/gen/iop/control.pb.go | 16 +- proto/gen/iop/runtime.pb.go | 10 +- proto/iop/control.proto | 2 + proto/iop/runtime.proto | 10 +- 16 files changed, 545 insertions(+), 32 deletions(-) create mode 100644 agent-roadmap/phase/automation-runtime-bridge/milestones/pi-cli-provider-integration.md create mode 100644 apps/edge/internal/edgevalidate/validate_test.go diff --git a/agent-contract/inner/control-plane-edge-wire.md b/agent-contract/inner/control-plane-edge-wire.md index dc0d247..d025aea 100644 --- a/agent-contract/inner/control-plane-edge-wire.md +++ b/agent-contract/inner/control-plane-edge-wire.md @@ -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 계약에 쓰지 않는다. diff --git a/agent-contract/inner/edge-config-runtime-refresh.md b/agent-contract/inner/edge-config-runtime-refresh.md index 3b2843f..477629e 100644 --- a/agent-contract/inner/edge-config-runtime-refresh.md +++ b/agent-contract/inner/edge-config-runtime-refresh.md @@ -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. diff --git a/agent-contract/inner/edge-node-runtime-wire.md b/agent-contract/inner/edge-node-runtime-wire.md index a5a5127..a0fc79d 100644 --- a/agent-contract/inner/edge-node-runtime-wire.md +++ b/agent-contract/inner/edge-node-runtime-wire.md @@ -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뿐이다. ## 금지 사항 diff --git a/agent-roadmap/phase/automation-runtime-bridge/PHASE.md b/agent-roadmap/phase/automation-runtime-bridge/PHASE.md index 4eb81c5..9b0f4c2 100644 --- a/agent-roadmap/phase/automation-runtime-bridge/PHASE.md +++ b/agent-roadmap/phase/automation-runtime-bridge/PHASE.md @@ -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` 계약을 정리한다. diff --git a/agent-roadmap/phase/automation-runtime-bridge/milestones/pi-cli-provider-integration.md b/agent-roadmap/phase/automation-runtime-bridge/milestones/pi-cli-provider-integration.md new file mode 100644 index 0000000..235f76a --- /dev/null +++ b/agent-roadmap/phase/automation-runtime-bridge/milestones/pi-cli-provider-integration.md @@ -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 후보로 포함한다. +- 확인 필요: 없음 diff --git a/agent-task/m-node-resource-model-unification/01_contract_schema/CODE_REVIEW-cloud-G07.md b/agent-task/m-node-resource-model-unification/01_contract_schema/CODE_REVIEW-cloud-G07.md index d3b8864..882b79d 100644 --- a/agent-task/m-node-resource-model-unification/01_contract_schema/CODE_REVIEW-cloud-G07.md +++ b/agent-task/m-node-resource-model-unification/01_contract_schema/CODE_REVIEW-cloud-G07.md @@ -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?** diff --git a/apps/edge/internal/bootstrap/runtime_test.go b/apps/edge/internal/bootstrap/runtime_test.go index 22ca720..1eff4d5 100644 --- a/apps/edge/internal/bootstrap/runtime_test.go +++ b/apps/edge/internal/bootstrap/runtime_test.go @@ -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 diff --git a/apps/edge/internal/configrefresh/classify.go b/apps/edge/internal/configrefresh/classify.go index d350ad1..44a0bcf 100644 --- a/apps/edge/internal/configrefresh/classify.go +++ b/apps/edge/internal/configrefresh/classify.go @@ -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 { diff --git a/apps/edge/internal/configrefresh/classify_test.go b/apps/edge/internal/configrefresh/classify_test.go index 55fb78e..f590989 100644 --- a/apps/edge/internal/configrefresh/classify_test.go +++ b/apps/edge/internal/configrefresh/classify_test.go @@ -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 ` diff --git a/apps/edge/internal/edgevalidate/validate.go b/apps/edge/internal/edgevalidate/validate.go index 238a3a2..bf251b4 100644 --- a/apps/edge/internal/edgevalidate/validate.go +++ b/apps/edge/internal/edgevalidate/validate.go @@ -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 diff --git a/apps/edge/internal/edgevalidate/validate_test.go b/apps/edge/internal/edgevalidate/validate_test.go new file mode 100644 index 0000000..6609c50 --- /dev/null +++ b/apps/edge/internal/edgevalidate/validate_test.go @@ -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) + } +} diff --git a/packages/go/config/config.go b/packages/go/config/config.go index b6aee47..3e7a4b4 100644 --- a/packages/go/config/config.go +++ b/packages/go/config/config.go @@ -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"` } 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 } diff --git a/proto/iop/control.proto b/proto/iop/control.proto index 6d17cf7..a8d05d1 100644 --- a/proto/iop/control.proto +++ b/proto/iop/control.proto @@ -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; } diff --git a/proto/iop/runtime.proto b/proto/iop/runtime.proto index ac1509f..7ba1562 100644 --- a/proto/iop/runtime.proto +++ b/proto/iop/runtime.proto @@ -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; }