refactor: edge service split and OpenAI surface separation

- Split edge service into dedicated modules (control_command, node_command, run_dispatch, status_provider)
- Separate OpenAI handlers (chat_handler, ollama_passthrough, routes, stream, strict_output, types)
- Archive completed milestone documents (02_edge_service_split, 03+02_openai_surface_split)
- Update architecture refactor foundation milestone
This commit is contained in:
toki 2026-06-06 12:18:33 +09:00
parent 9c7e5c342d
commit 6b684ee5cf
21 changed files with 1876 additions and 1567 deletions

View file

@ -34,7 +34,7 @@ Edge, Node, Control Plane, Client, proto/config 경계에서 책임이 섞인
Edge 내부에서 CLI command, service orchestration, external compatibility surface가 서로 커지는 것을 막는다.
- [ ] [edge-cmd-split] `apps/edge/cmd/edge/main.go`의 config resolution, bootstrap pack, node registration, YAML patch, smoke client 로직을 기존 동작을 유지한 채 내부 패키지 또는 작은 모듈로 분리한다.
- [x] [edge-cmd-split] `apps/edge/cmd/edge/main.go`의 config resolution, bootstrap pack, node registration, YAML patch, smoke client 로직을 기존 동작을 유지한 채 내부 패키지 또는 작은 모듈로 분리한다.
- [ ] [edge-service-split] `apps/edge/internal/service`를 run dispatch, node command, control command, capability/status provider 책임으로 분리하고 health simulation 같은 임시 command 구현을 실제 상태 조회 경계와 분리한다.
- [ ] [openai-surface-split] `apps/edge/internal/openai`에서 HTTP lifecycle, chat request mapping, run stream/result collection, strict output policy, Ollama passthrough를 분리해 OpenAI-compatible 표면이 IOP native 기능을 흡수하지 않게 한다.
- [x] [event-bus-contract] Edge event bus의 drop, replay, immutability, future persistence 정책을 코드 경계와 테스트로 명확히 한다. 검증: `go test ./apps/edge/internal/events` 통과.

View file

@ -42,41 +42,47 @@ task=m-architecture-refactor-foundation/02_edge_service_split, plan=0, tag=REFAC
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] Split Service Responsibilities | [ ] |
| [REFACTOR-1] Split Service Responsibilities | [x] |
## 구현 체크리스트
- [ ] `apps/edge/internal/service`를 같은 package 안에서 `service.go`, `status_provider.go`, `run_dispatch.go`, `node_command.go`, `control_command.go` 같은 책임별 파일로 나눈다.
- [ ] `ExecuteCommand("health.check")`의 `time.Sleep` 기반 임시 성공을 제거하고 `ListNodeSnapshots`/`GetCapabilities` 기반 `HealthStatus` 또는 equivalent private boundary를 사용하게 한다.
- [ ] 기존 public service API를 유지하고 openai/A2A/opsconsole/controlplane call-site 변경이 없거나 mechanical import-only 수준인지 확인한다.
- [ ] `go test ./apps/edge/internal/service`와 `go test ./apps/edge/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `apps/edge/internal/service`를 같은 package 안에서 `service.go`, `status_provider.go`, `run_dispatch.go`, `node_command.go`, `control_command.go` 같은 책임별 파일로 나눈다.
- [x] `ExecuteCommand("health.check")`의 `time.Sleep` 기반 임시 성공을 제거하고 `ListNodeSnapshots`/`GetCapabilities` 기반 `HealthStatus` 또는 equivalent private boundary를 사용하게 한다.
- [x] 기존 public service API를 유지하고 openai/A2A/opsconsole/controlplane call-site 변경이 없거나 mechanical import-only 수준인지 확인한다.
- [x] `go test ./apps/edge/internal/service`와 `go test ./apps/edge/...`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G05_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G05_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/02_edge_service_split/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/02_edge_service_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G05_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G05_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/02_edge_service_split/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/02_edge_service_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G05.md`와 `CODE_REVIEW-cloud-G05.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- 파일 분할은 계획의 5개 파일 구성을 그대로 따랐다. public method/type, import path, 외부 call-site는 변경하지 않았으므로 openai/A2A/opsconsole/controlplane 측 수정은 0건이다.
- 계획은 `health.check`가 "registry/capability snapshot을 사용한다"는 점만 명시했다. 구현에서는 `status_provider.go`에 `HealthStatusView` DTO와 `HealthStatus()` (`ListNodeSnapshots()` + `GetCapabilities()` 합성)를 추가하고, 응답/이벤트 summary를 `"Edge health check OK: N node(s), M capability(ies)"`로 status에서 파생되게 했다. 고정 문자열/sleep 성공을 제거했다.
- `control_command.go`로 옮긴 `edgeCommandResponseFromNodeCommand`의 결과 key 정렬을 기존 수기 버블 정렬(`importSort` 클로저)에서 표준 `sort.Strings`로 대체했다. 오름차순 정렬 동작은 동일하며 기존 success 테스트(`caps=cli,codex` summary)가 그대로 통과한다.
- 검증 명령은 계획/리뷰의 고정 계약(`go test ./apps/edge/internal/service`, `go test ./apps/edge/...`)을 그대로 실행했다. 대체 없음.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- 분할은 같은 `package service` 안에서 파일만 책임별로 나누는 방식으로 했다. 패키지 경계나 export 표면을 바꾸지 않아 모든 호출부가 import-only 변경조차 없이 그대로 컴파일된다.
- 책임 배치: `service.go`(Service 구조체/생성자/공통 helper: `NormalizeSessionID`, `normalizeTimeoutSec`, `nodeLabel`), `status_provider.go`(node snapshot/capability/domain agent/`HealthStatus`), `run_dispatch.go`(SubmitRun/Cancel/TerminateSession/run request 빌더), `node_command.go`(NodeCommand transport: usage/capabilities/session/transport/Ollama), `control_command.go`(`ExecuteCommand` 및 Edge command 응답 helper).
- Cancel/TerminateSession은 node로 메시지를 보내지만 run lifecycle 의미가 강해 `run_dispatch.go`에 두고, `NodeCommandRequest` 기반 조회성 명령만 `node_command.go`로 모았다.
- `HealthStatus()`를 export된 `HealthStatusView` 반환 method로 둬서 향후 Control Plane status 표면이 동일 경계를 재사용할 수 있게 했다. health summary가 실제 registry/capability 수에서 파생됨을 `TestServiceExecuteCommandHealthCheckDerivesFromStatus`(노드 있음/없음 두 케이스)로 고정했다.
## 사용자 리뷰 요청
@ -110,13 +116,24 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
### REFACTOR-1 중간 검증
```bash
$ go test ./apps/edge/internal/service
(output)
ok iop/apps/edge/internal/service 0.005s
```
### 최종 검증
```bash
$ go test ./apps/edge/...
(output)
ok iop/apps/edge/cmd/edge 0.029s
ok iop/apps/edge/internal/bootstrap 0.016s
ok iop/apps/edge/internal/controlplane 4.451s
ok iop/apps/edge/internal/edgecmd (cached)
ok iop/apps/edge/internal/events (cached)
ok iop/apps/edge/internal/input 0.005s
ok iop/apps/edge/internal/input/a2a 0.005s
ok iop/apps/edge/internal/node (cached)
ok iop/apps/edge/internal/openai 1.507s
ok iop/apps/edge/internal/opsconsole 0.007s
ok iop/apps/edge/internal/service (cached)
ok iop/apps/edge/internal/transport (cached)
```
---
@ -124,3 +141,21 @@ $ go test ./apps/edge/...
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 리뷰 검증:
- `go test -count=1 ./apps/edge/internal/service` 통과 (`ok iop/apps/edge/internal/service 0.006s`)
- `go test -count=1 ./apps/edge/...` 통과
- `./scripts/e2e-smoke.sh` 통과: mock profile에서 `scripts/dev/edge.sh`/`scripts/dev/node.sh` 기반 node registration, `/nodes`, `/capabilities`, `/transport`, 메시지 2회, background run, `/sessions`, `/terminate-session` 확인
- 다음 단계: PASS이므로 `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,42 @@
# Complete - m-architecture-refactor-foundation/02_edge_service_split
## 완료 일시
2026-06-06
## 요약
Edge service 책임 분리와 status-backed `health.check` 전환을 첫 리뷰 루프에서 PASS로 완료했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G05_0.log` | `code_review_cloud_G05_0.log` | PASS | Service public API를 유지한 파일 분리, `HealthStatus()` 기반 health summary, service/edge 테스트와 mock edge-node smoke 통과를 확인했다. |
## 구현/정리 내용
- `apps/edge/internal/service`의 책임을 `service.go`, `status_provider.go`, `run_dispatch.go`, `node_command.go`, `control_command.go`로 분리했다.
- `ExecuteCommand("health.check")`의 fixed sleep/fake success를 제거하고 `ListNodeSnapshots()`와 `GetCapabilities()`로 만든 `HealthStatus()` summary를 응답과 completed event에 사용했다.
- 기존 openai/A2A/opsconsole/controlplane call-site가 같은 public service API로 컴파일되는지 확인하고, health command status summary 테스트를 추가했다.
## 최종 검증
- `go test -count=1 ./apps/edge/internal/service` - PASS; `ok iop/apps/edge/internal/service 0.006s`
- `go test -count=1 ./apps/edge/...` - PASS; edge 하위 모든 Go package 통과
- `./scripts/e2e-smoke.sh` - PASS; mock profile에서 node registration, `/nodes`, `/capabilities`, `/transport`, 메시지 2회, background run, `/sessions`, `/terminate-session` 확인
## Roadmap Completion
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- Completed task ids:
- `edge-service-split`: PASS; evidence=`agent-task/archive/2026/06/m-architecture-refactor-foundation/02_edge_service_split/plan_cloud_G05_0.log`, `agent-task/archive/2026/06/m-architecture-refactor-foundation/02_edge_service_split/code_review_cloud_G05_0.log`; verification=`go test -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`, `./scripts/e2e-smoke.sh`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -42,41 +42,41 @@ task=m-architecture-refactor-foundation/03+02_openai_surface_split, plan=0, tag=
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] Split OpenAI-Compatible Surface Responsibilities | [ ] |
| [REFACTOR-1] Split OpenAI-Compatible Surface Responsibilities | [x] |
## 구현 체크리스트
- [ ] Confirm predecessor `02_edge_service_split` has `complete.log` before implementation; do not start from this plan while predecessor is active/incomplete.
- [ ] Split `apps/edge/internal/openai/server.go` into lifecycle/routes, chat request mapping, run collection/SSE streaming, strict output policy, Ollama passthrough, HTTP response/types files while keeping package API stable.
- [ ] Keep OpenAI-compatible behavior unchanged: request validation, model target resolution, strict output policy, SSE chunks, Ollama passthrough status/content-type/body.
- [ ] `go test ./apps/edge/internal/openai`와 `go test ./apps/edge/...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] Confirm predecessor `02_edge_service_split` has `complete.log` before implementation; do not start from this plan while predecessor is active/incomplete.
- [x] Split `apps/edge/internal/openai/server.go` into lifecycle/routes, chat request mapping, run collection/SSE streaming, strict output policy, Ollama passthrough, HTTP response/types files while keeping package API stable.
- [x] Keep OpenAI-compatible behavior unchanged: request validation, model target resolution, strict output policy, SSE chunks, Ollama passthrough status/content-type/body.
- [x] `go test ./apps/edge/internal/openai`와 `go test ./apps/edge/...`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G05_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G05_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/03+02_openai_surface_split/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/03+02_openai_surface_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G05_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G05_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/03+02_openai_surface_split/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/03+02_openai_surface_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G05.md`와 `CODE_REVIEW-cloud-G05.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- `chat_handler.go`에서 `errors` 외에 `context`를 추가로 임포트하여 `context.Canceled` 참조 오류를 방지했습니다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- `server.go`에 존재하던 복잡한 HTTP 핸들러 및 헬퍼 로직을 각각의 기능적 성격에 맞게 `routes.go`, `chat_handler.go`, `run_result.go`, `stream.go`, `strict_output.go`, `ollama_passthrough.go`, `types.go` 파일로 분할하여 단일 책임 원칙을 강화하고 가독성을 높였습니다.
## 사용자 리뷰 요청
@ -98,25 +98,27 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REFACTOR-1 중간 검증
```bash
$ go test ./apps/edge/internal/openai
(output)
ok iop/apps/edge/internal/openai 1.505s
```
### 최종 검증
```bash
$ go test ./apps/edge/...
(output)
ok iop/apps/edge/cmd/edge 0.039s
ok iop/apps/edge/internal/bootstrap 0.024s
ok iop/apps/edge/internal/controlplane 4.444s
ok iop/apps/edge/internal/edgecmd 0.012s
ok iop/apps/edge/internal/events 0.004s
ok iop/apps/edge/internal/input 0.010s
ok iop/apps/edge/internal/input/a2a 0.011s
ok iop/apps/edge/internal/node 0.005s
ok iop/apps/edge/internal/openai 1.507s
ok iop/apps/edge/internal/opsconsole 0.006s
ok iop/apps/edge/internal/service 0.006s
ok iop/apps/edge/internal/transport 2.009s
```
---
@ -124,3 +126,22 @@ $ go test ./apps/edge/...
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS 절차로 active plan/review를 archive하고 `complete.log`를 작성한 뒤 task directory를 `agent-task/archive/2026/06/m-architecture-refactor-foundation/03+02_openai_surface_split/`로 이동한다.
리뷰 메모:
- `02_edge_service_split` predecessor는 `agent-task/archive/2026/06/m-architecture-refactor-foundation/02_edge_service_split/complete.log`로 완료 상태를 확인했다.
- `server.go`의 HTTP lifecycle은 `routes()` 위임만 남기고, chat handler, run result collection, SSE streaming, strict output policy, Ollama passthrough, DTO가 package 내부 파일로 분리되었으며 package API는 유지된다.
- 구현자 검증 명령을 재실행했고, OpenAI-compatible edge-node-Ollama 수렴 확인을 위해 `./scripts/e2e-openai-ollama.sh`도 추가 실행했다.

View file

@ -0,0 +1,42 @@
# Complete - m-architecture-refactor-foundation/03+02_openai_surface_split
## 완료 일시
2026-06-06
## 요약
OpenAI-compatible HTTP surface split task completed in 1 review loop with final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G05_0.log` | `code_review_cloud_G05_0.log` | PASS | OpenAI lifecycle/routes, chat mapping, run result/SSE, strict output, Ollama passthrough, DTO boundaries were split without behavior regression. |
## 구현/정리 내용
- Split `apps/edge/internal/openai/server.go` into focused package files for routes, chat handling, run result collection, streaming, strict output normalization, Ollama passthrough, and DTOs.
- Preserved OpenAI-compatible behavior and the existing `NewServer`/`Start` package API used by `apps/edge/internal/input`.
- Confirmed predecessor task completion via archived `02_edge_service_split/complete.log`.
## 최종 검증
- `go test ./apps/edge/internal/openai` - PASS; `ok iop/apps/edge/internal/openai 1.506s`.
- `go test ./apps/edge/...` - PASS; all edge packages passed, with some unchanged packages served from Go test cache.
- `./scripts/e2e-openai-ollama.sh` - PASS; `[openai-ollama] OpenAI-compatible Ollama serving test PASSED.`
## Roadmap Completion
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- Completed task ids:
- `openai-surface-split`: PASS; evidence=`agent-task/archive/2026/06/m-architecture-refactor-foundation/03+02_openai_surface_split/plan_cloud_G05_0.log`, `agent-task/archive/2026/06/m-architecture-refactor-foundation/03+02_openai_surface_split/code_review_cloud_G05_0.log`; verification=`go test ./apps/edge/internal/openai`, `go test ./apps/edge/...`, `./scripts/e2e-openai-ollama.sh`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,213 @@
package openai
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"go.uber.org/zap"
edgeservice "iop/apps/edge/internal/service"
)
func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
defer r.Body.Close()
var req chatCompletionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", "invalid JSON request")
return
}
target := s.resolveTarget(req.Model)
if target == "" {
writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
basePrompt := promptFromMessages(req.Messages)
if strings.TrimSpace(basePrompt) == "" {
writeError(w, http.StatusBadRequest, "invalid_request_error", "messages are required")
return
}
outputPolicy := s.resolveOutputPolicy(basePrompt)
messages := req.Messages
if instruction := strictOutputContractInstruction(outputPolicy); instruction != "" {
messages = prependSystemMessage(messages, instruction)
}
prompt := promptFromMessages(messages)
input := req.runInput(prompt, messages, outputPolicy.Strict)
s.logger.Info("openai chat completion input",
zap.String("model", req.Model),
zap.String("target", target),
zap.String("adapter", s.resolveAdapter()),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
zap.Bool("contract_instruction", outputPolicy.ContractInstruction),
zap.Bool("stream", req.Stream),
zap.Int("message_count", len(req.Messages)),
zap.Int("prompt_len", len(prompt)),
zap.String("prompt_preview", previewString(prompt, 1000)),
zap.Any("input_keys", mapKeys(input)),
)
handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{
NodeRef: s.cfg.NodeRef,
Adapter: s.resolveAdapter(),
Target: target,
SessionID: s.resolveSessionID(),
Prompt: prompt,
Input: input,
TimeoutSec: s.resolveTimeoutSec(),
Metadata: map[string]string{
"source": "openai",
"openai_model": req.Model,
"openai_stream": fmt.Sprintf("%t", req.Stream),
"strict_output": fmt.Sprintf("%t", outputPolicy.Strict),
},
})
if err != nil {
writeError(w, http.StatusBadGateway, "node_dispatch_error", err.Error())
return
}
defer handle.Close()
if req.Stream {
s.streamChatCompletion(w, r, req, handle, outputPolicy)
return
}
s.completeChatCompletion(w, r, req, handle, outputPolicy)
}
func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle, outputPolicy strictOutputPolicy) {
text, reasoning, usage, err := collectRunResult(r.Context(), handle)
if err != nil {
writeError(w, httpStatusForRunError(err), "run_error", err.Error())
return
}
text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning)
s.logger.Info("openai chat completion output",
zap.String("run_id", handle.RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
zap.Bool("normalized", normalized),
zap.Int("content_len", len(text)),
zap.Int("reasoning_len", len(reasoning)),
zap.String("content_preview", previewString(text, 1000)),
)
created := time.Now().Unix()
writeJSON(w, http.StatusOK, chatCompletionResponse{
ID: "chatcmpl-" + handle.RunID,
Object: "chat.completion",
Created: created,
Model: responseModel(req.Model, handle.Target),
Choices: []chatCompletionChoice{{
Index: 0,
Message: chatMessage{Role: "assistant", Content: text, ReasoningContent: reasoning},
FinishReason: "stop",
}},
Usage: usage,
})
}
func (s *Server) resolveAdapter() string {
if s.cfg.Adapter != "" {
return s.cfg.Adapter
}
return "ollama"
}
func (s *Server) resolveTarget(model string) string {
if s.cfg.Target != "" {
return s.cfg.Target
}
return strings.TrimSpace(model)
}
func (s *Server) resolveSessionID() string {
if s.cfg.SessionID != "" {
return s.cfg.SessionID
}
return edgeservice.DefaultSessionID
}
func (s *Server) resolveTimeoutSec() int {
if s.cfg.TimeoutSec > 0 {
return s.cfg.TimeoutSec
}
return edgeservice.DefaultTimeoutSec
}
func (s *Server) resolveStrictOutput() bool {
return s.cfg.StrictOutput
}
func (s *Server) resolveStrictStreamBuffer() bool {
return s.cfg.StrictStreamBuffer
}
func (s *Server) resolveOutputPolicy(prompt string) strictOutputPolicy {
policy := strictOutputPolicy{
Strict: s.resolveStrictOutput(),
StreamBuffer: s.resolveStrictStreamBuffer(),
}
if !policy.Strict {
return policy
}
policy.XMLCompletionTool, policy.XMLResultTag = inferXMLCompletionContract(prompt)
policy.ContractInstruction = policy.XMLCompletionTool != ""
return policy
}
func promptFromMessages(messages []chatMessage) string {
var b strings.Builder
for _, msg := range messages {
content := strings.TrimSpace(msg.Content)
if content == "" {
continue
}
role := strings.TrimSpace(msg.Role)
if role == "" {
role = "user"
}
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(role)
b.WriteString(": ")
b.WriteString(content)
}
return b.String()
}
func responseModel(requestModel, target string) string {
if requestModel != "" {
return requestModel
}
return target
}
func httpStatusForRunError(err error) int {
if errors.Is(err, context.Canceled) {
return http.StatusRequestTimeout
}
return http.StatusBadGateway
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{Error: errorBody{Type: code, Message: message}})
}

View file

@ -0,0 +1,44 @@
package openai
import (
"io"
"net/http"
edgeservice "iop/apps/edge/internal/service"
)
func (s *Server) handleOllamaAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodDelete {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
defer r.Body.Close()
body, err := io.ReadAll(io.LimitReader(r.Body, 16<<20))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", "read request body failed")
return
}
resp, err := s.service.OllamaAPI(r.Context(), edgeservice.OllamaAPIRequest{
NodeRef: s.cfg.NodeRef,
Adapter: s.resolveAdapter(),
Method: r.Method,
Path: r.URL.RequestURI(),
Body: string(body),
TimeoutSec: s.resolveTimeoutSec(),
})
if err != nil {
writeError(w, http.StatusBadGateway, "ollama_passthrough_error", err.Error())
return
}
contentType := resp.ContentType
if contentType == "" {
contentType = "application/json"
}
w.Header().Set("Content-Type", contentType)
status := resp.StatusCode
if status < 100 || status > 999 {
status = http.StatusOK
}
w.WriteHeader(status)
_, _ = w.Write([]byte(resp.Body))
}

View file

@ -0,0 +1,45 @@
package openai
import (
"net/http"
"strings"
"time"
)
func (s *Server) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealthz)
mux.HandleFunc("/v1/models", s.handleModels)
mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions)
mux.HandleFunc("/api/", s.handleOllamaAPI)
return mux
}
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
models := s.cfg.Models
if len(models) == 0 && s.cfg.Target != "" {
models = []string{s.cfg.Target}
}
data := make([]openAIModel, 0, len(models))
for _, model := range models {
model = strings.TrimSpace(model)
if model == "" {
continue
}
data = append(data, openAIModel{
ID: model,
Object: "model",
Created: time.Now().Unix(),
OwnedBy: "iop",
})
}
writeJSON(w, http.StatusOK, openAIModelsResponse{Object: "list", Data: data})
}

View file

@ -0,0 +1,58 @@
package openai
import (
"context"
"fmt"
"strings"
"time"
edgeservice "iop/apps/edge/internal/service"
)
func collectRunResult(ctx context.Context, handle *edgeservice.RunHandle) (string, string, *openAIUsage, error) {
var contentBuilder strings.Builder
var reasoningBuilder strings.Builder
var usage *openAIUsage
timer := time.NewTimer(handle.WaitTimeout())
defer timer.Stop()
for {
select {
case <-ctx.Done():
return "", "", nil, ctx.Err()
case <-timer.C:
return "", "", nil, fmt.Errorf("run timed out")
case nodeEvent := <-handle.NodeEvents:
if edgeservice.IsNodeDisconnected(nodeEvent) {
return "", "", nil, fmt.Errorf("node disconnected")
}
case event := <-handle.Events:
if event == nil {
continue
}
switch event.GetType() {
case "delta":
contentBuilder.WriteString(event.GetDelta())
case "reasoning_delta":
reasoningBuilder.WriteString(event.GetDelta())
case "complete":
if u := event.GetUsage(); u != nil {
usage = &openAIUsage{
PromptTokens: int(u.GetInputTokens()),
CompletionTokens: int(u.GetOutputTokens()),
TotalTokens: int(u.GetInputTokens() + u.GetOutputTokens()),
}
}
return contentBuilder.String(), reasoningBuilder.String(), usage, nil
case "error", "cancelled":
msg := event.GetError()
if msg == "" {
msg = event.GetMessage()
}
if msg == "" {
msg = "run failed"
}
return "", "", nil, fmt.Errorf("%s", msg)
}
}
}
}

View file

@ -2,15 +2,10 @@ package openai
import (
"context"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net"
"net/http"
"regexp"
"strings"
"time"
"go.uber.org/zap"
@ -50,11 +45,7 @@ func (s *Server) Start(ctx context.Context) error {
s.cfg.Listen = "0.0.0.0:8080"
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealthz)
mux.HandleFunc("/v1/models", s.handleModels)
mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions)
mux.HandleFunc("/api/", s.handleOllamaAPI)
mux := s.routes()
s.server = &http.Server{
Addr: s.cfg.Listen,
@ -86,818 +77,3 @@ func (s *Server) Stop(ctx context.Context) error {
}
return s.server.Shutdown(ctx)
}
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
models := s.cfg.Models
if len(models) == 0 && s.cfg.Target != "" {
models = []string{s.cfg.Target}
}
data := make([]openAIModel, 0, len(models))
for _, model := range models {
model = strings.TrimSpace(model)
if model == "" {
continue
}
data = append(data, openAIModel{
ID: model,
Object: "model",
Created: time.Now().Unix(),
OwnedBy: "iop",
})
}
writeJSON(w, http.StatusOK, openAIModelsResponse{Object: "list", Data: data})
}
func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
defer r.Body.Close()
var req chatCompletionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", "invalid JSON request")
return
}
target := s.resolveTarget(req.Model)
if target == "" {
writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
basePrompt := promptFromMessages(req.Messages)
if strings.TrimSpace(basePrompt) == "" {
writeError(w, http.StatusBadRequest, "invalid_request_error", "messages are required")
return
}
outputPolicy := s.resolveOutputPolicy(basePrompt)
messages := req.Messages
if instruction := strictOutputContractInstruction(outputPolicy); instruction != "" {
messages = prependSystemMessage(messages, instruction)
}
prompt := promptFromMessages(messages)
input := req.runInput(prompt, messages, outputPolicy.Strict)
s.logger.Info("openai chat completion input",
zap.String("model", req.Model),
zap.String("target", target),
zap.String("adapter", s.resolveAdapter()),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
zap.Bool("contract_instruction", outputPolicy.ContractInstruction),
zap.Bool("stream", req.Stream),
zap.Int("message_count", len(req.Messages)),
zap.Int("prompt_len", len(prompt)),
zap.String("prompt_preview", previewString(prompt, 1000)),
zap.Any("input_keys", mapKeys(input)),
)
handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{
NodeRef: s.cfg.NodeRef,
Adapter: s.resolveAdapter(),
Target: target,
SessionID: s.resolveSessionID(),
Prompt: prompt,
Input: input,
TimeoutSec: s.resolveTimeoutSec(),
Metadata: map[string]string{
"source": "openai",
"openai_model": req.Model,
"openai_stream": fmt.Sprintf("%t", req.Stream),
"strict_output": fmt.Sprintf("%t", outputPolicy.Strict),
},
})
if err != nil {
writeError(w, http.StatusBadGateway, "node_dispatch_error", err.Error())
return
}
defer handle.Close()
if req.Stream {
s.streamChatCompletion(w, r, req, handle, outputPolicy)
return
}
s.completeChatCompletion(w, r, req, handle, outputPolicy)
}
func (s *Server) handleOllamaAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodDelete {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
defer r.Body.Close()
body, err := io.ReadAll(io.LimitReader(r.Body, 16<<20))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", "read request body failed")
return
}
resp, err := s.service.OllamaAPI(r.Context(), edgeservice.OllamaAPIRequest{
NodeRef: s.cfg.NodeRef,
Adapter: s.resolveAdapter(),
Method: r.Method,
Path: r.URL.RequestURI(),
Body: string(body),
TimeoutSec: s.resolveTimeoutSec(),
})
if err != nil {
writeError(w, http.StatusBadGateway, "ollama_passthrough_error", err.Error())
return
}
contentType := resp.ContentType
if contentType == "" {
contentType = "application/json"
}
w.Header().Set("Content-Type", contentType)
status := resp.StatusCode
if status < 100 || status > 999 {
status = http.StatusOK
}
w.WriteHeader(status)
_, _ = w.Write([]byte(resp.Body))
}
func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle, outputPolicy strictOutputPolicy) {
text, reasoning, usage, err := collectRunResult(r.Context(), handle)
if err != nil {
writeError(w, httpStatusForRunError(err), "run_error", err.Error())
return
}
text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning)
s.logger.Info("openai chat completion output",
zap.String("run_id", handle.RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
zap.Bool("normalized", normalized),
zap.Int("content_len", len(text)),
zap.Int("reasoning_len", len(reasoning)),
zap.String("content_preview", previewString(text, 1000)),
)
created := time.Now().Unix()
writeJSON(w, http.StatusOK, chatCompletionResponse{
ID: "chatcmpl-" + handle.RunID,
Object: "chat.completion",
Created: created,
Model: responseModel(req.Model, handle.Target),
Choices: []chatCompletionChoice{{
Index: 0,
Message: chatMessage{Role: "assistant", Content: text, ReasoningContent: reasoning},
FinishReason: "stop",
}},
Usage: usage,
})
}
func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle, outputPolicy strictOutputPolicy) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming_not_supported", "response writer does not support streaming")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
created := time.Now().Unix()
id := "chatcmpl-" + handle.RunID
model := responseModel(req.Model, handle.Target)
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{Role: "assistant"},
}},
})
if outputPolicy.Strict && outputPolicy.StreamBuffer {
s.streamBufferedChatCompletion(w, r, handle, id, created, model, flusher, outputPolicy)
return
}
var contentBuilder strings.Builder
var reasoningBuilder strings.Builder
defer func() {
s.logger.Info("openai chat completion stream closed",
zap.String("run_id", handle.RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.Int("content_len", contentBuilder.Len()),
zap.Int("reasoning_len", reasoningBuilder.Len()),
zap.String("content_preview", previewString(contentBuilder.String(), 1000)),
zap.String("reasoning_preview", previewString(reasoningBuilder.String(), 1000)),
)
}()
for {
select {
case <-r.Context().Done():
return
case nodeEvent := <-handle.NodeEvents:
if edgeservice.IsNodeDisconnected(nodeEvent) {
writeSSEError(w, flusher, "node disconnected")
return
}
case event := <-handle.Events:
if event == nil {
continue
}
switch event.GetType() {
case "delta":
if event.GetDelta() == "" {
continue
}
contentBuilder.WriteString(event.GetDelta())
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{Content: event.GetDelta()},
}},
})
case "reasoning_delta":
if event.GetDelta() == "" {
continue
}
reasoningBuilder.WriteString(event.GetDelta())
if outputPolicy.Strict {
continue
}
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{ReasoningContent: event.GetDelta()},
}},
})
case "complete":
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{},
FinishReason: "stop",
}},
})
fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
return
case "error", "cancelled":
msg := event.GetError()
if msg == "" {
msg = event.GetMessage()
}
if msg == "" {
msg = "run failed"
}
writeSSEError(w, flusher, msg)
return
}
case <-time.After(handle.WaitTimeout()):
writeSSEError(w, flusher, "run timed out")
return
}
}
}
func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Request, handle *edgeservice.RunHandle, id string, created int64, model string, flusher http.Flusher, outputPolicy strictOutputPolicy) {
text, reasoning, _, err := collectRunResult(r.Context(), handle)
if err != nil {
writeSSEError(w, flusher, err.Error())
return
}
text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning)
s.logger.Info("openai chat completion stream closed",
zap.String("run_id", handle.RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
zap.Bool("normalized", normalized),
zap.Int("content_len", len(text)),
zap.Int("reasoning_len", len(reasoning)),
zap.String("content_preview", previewString(text, 1000)),
zap.String("reasoning_preview", previewString(reasoning, 1000)),
)
if text != "" {
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{Content: text},
}},
})
}
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{},
FinishReason: "stop",
}},
})
fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
}
func collectRunResult(ctx context.Context, handle *edgeservice.RunHandle) (string, string, *openAIUsage, error) {
var contentBuilder strings.Builder
var reasoningBuilder strings.Builder
var usage *openAIUsage
timer := time.NewTimer(handle.WaitTimeout())
defer timer.Stop()
for {
select {
case <-ctx.Done():
return "", "", nil, ctx.Err()
case <-timer.C:
return "", "", nil, fmt.Errorf("run timed out")
case nodeEvent := <-handle.NodeEvents:
if edgeservice.IsNodeDisconnected(nodeEvent) {
return "", "", nil, fmt.Errorf("node disconnected")
}
case event := <-handle.Events:
if event == nil {
continue
}
switch event.GetType() {
case "delta":
contentBuilder.WriteString(event.GetDelta())
case "reasoning_delta":
reasoningBuilder.WriteString(event.GetDelta())
case "complete":
if u := event.GetUsage(); u != nil {
usage = &openAIUsage{
PromptTokens: int(u.GetInputTokens()),
CompletionTokens: int(u.GetOutputTokens()),
TotalTokens: int(u.GetInputTokens() + u.GetOutputTokens()),
}
}
return contentBuilder.String(), reasoningBuilder.String(), usage, nil
case "error", "cancelled":
msg := event.GetError()
if msg == "" {
msg = event.GetMessage()
}
if msg == "" {
msg = "run failed"
}
return "", "", nil, fmt.Errorf("%s", msg)
}
}
}
}
func (s *Server) resolveAdapter() string {
if s.cfg.Adapter != "" {
return s.cfg.Adapter
}
return "ollama"
}
func (s *Server) resolveTarget(model string) string {
if s.cfg.Target != "" {
return s.cfg.Target
}
return strings.TrimSpace(model)
}
func (s *Server) resolveSessionID() string {
if s.cfg.SessionID != "" {
return s.cfg.SessionID
}
return edgeservice.DefaultSessionID
}
func (s *Server) resolveTimeoutSec() int {
if s.cfg.TimeoutSec > 0 {
return s.cfg.TimeoutSec
}
return edgeservice.DefaultTimeoutSec
}
func (s *Server) resolveStrictOutput() bool {
return s.cfg.StrictOutput
}
func (s *Server) resolveStrictStreamBuffer() bool {
return s.cfg.StrictStreamBuffer
}
func (s *Server) resolveOutputPolicy(prompt string) strictOutputPolicy {
policy := strictOutputPolicy{
Strict: s.resolveStrictOutput(),
StreamBuffer: s.resolveStrictStreamBuffer(),
}
if !policy.Strict {
return policy
}
policy.XMLCompletionTool, policy.XMLResultTag = inferXMLCompletionContract(prompt)
policy.ContractInstruction = policy.XMLCompletionTool != ""
return policy
}
func promptFromMessages(messages []chatMessage) string {
var b strings.Builder
for _, msg := range messages {
content := strings.TrimSpace(msg.Content)
if content == "" {
continue
}
role := strings.TrimSpace(msg.Role)
if role == "" {
role = "user"
}
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(role)
b.WriteString(": ")
b.WriteString(content)
}
return b.String()
}
func responseModel(requestModel, target string) string {
if requestModel != "" {
return requestModel
}
return target
}
func httpStatusForRunError(err error) int {
if errors.Is(err, context.Canceled) {
return http.StatusRequestTimeout
}
return http.StatusBadGateway
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{Error: errorBody{Type: code, Message: message}})
}
func writeSSE(w http.ResponseWriter, flusher http.Flusher, v any) {
b, err := json.Marshal(v)
if err != nil {
return
}
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
}
func writeSSEError(w http.ResponseWriter, flusher http.Flusher, message string) {
writeSSE(w, flusher, errorResponse{Error: errorBody{Type: "run_error", Message: message}})
fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
}
type chatCompletionRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
Options map[string]any `json:"options,omitempty"`
Format any `json:"format,omitempty"`
KeepAlive any `json:"keep_alive,omitempty"`
Think any `json:"think,omitempty"`
Tools []any `json:"tools,omitempty"`
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
Images []any `json:"images,omitempty"`
ToolCalls []any `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"`
}
func (m *chatMessage) UnmarshalJSON(b []byte) error {
var raw struct {
Role string `json:"role"`
Content any `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
Images []any `json:"images,omitempty"`
ToolCalls []any `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"`
}
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
m.Role = raw.Role
m.Content = contentToString(raw.Content)
m.ReasoningContent = raw.ReasoningContent
m.Images = raw.Images
m.ToolCalls = raw.ToolCalls
m.ToolName = raw.ToolName
return nil
}
func (req chatCompletionRequest) runInput(prompt string, messages []chatMessage, strictOutput bool) map[string]any {
input := map[string]any{
"prompt": prompt,
"messages": chatMessagesInput(messages),
}
if len(req.Options) > 0 {
input["options"] = req.Options
}
if req.Format != nil {
input["format"] = req.Format
}
if req.KeepAlive != nil {
input["keep_alive"] = req.KeepAlive
}
if req.Think != nil {
input["think"] = req.Think
} else if strictOutput {
input["think"] = false
}
if len(req.Tools) > 0 {
input["tools"] = req.Tools
}
return input
}
type strictOutputPolicy struct {
Strict bool
StreamBuffer bool
XMLCompletionTool string
XMLResultTag string
ContractInstruction bool
}
func strictOutputContractInstruction(policy strictOutputPolicy) string {
if !policy.ContractInstruction {
return ""
}
resultTag := policy.XMLResultTag
if resultTag == "" {
resultTag = "result"
}
return fmt.Sprintf("Strict output contract: follow the client's XML tool protocol. When you complete the task, output exactly one <%s> block and no text outside that XML block. The <%s> content must be the direct final answer that should be shown to the end user. Do not summarize that you answered, completed, explained, greeted, or performed the task. Do not write phrases like \"I completed\", \"I explained\", \"I greeted\", \"summary\", or \"main points\" unless the user explicitly asks for a summary. Put the actual user-facing response inside <%s>.", policy.XMLCompletionTool, resultTag, resultTag)
}
func prependSystemMessage(messages []chatMessage, content string) []chatMessage {
out := make([]chatMessage, 0, len(messages)+1)
out = append(out, chatMessage{Role: "system", Content: content})
out = append(out, messages...)
return out
}
func normalizeCompletionOutput(policy strictOutputPolicy, content, reasoning string) (string, string, bool) {
if !policy.Strict {
return content, reasoning, false
}
normalized := normalizeStrictAgentContent(content)
if strings.TrimSpace(normalized) == "" && strings.TrimSpace(reasoning) != "" {
normalized = normalizeStrictAgentContent(reasoning)
}
if strings.TrimSpace(normalized) == "" {
normalized = "Model returned no assistant content."
}
if policy.XMLCompletionTool != "" && !startsWithXMLRoot(normalized) {
normalized = wrapXMLCompletion(policy, normalized)
}
return normalized, "", normalized != content || reasoning != ""
}
var (
agentThinkingBlockRE = regexp.MustCompile(`(?is)<think(?:ing)?\b[^>]*>.*?</think(?:ing)?\s*>`)
agentCodeFenceLineRE = regexp.MustCompile(`(?m)^\s*` + "```" + `[A-Za-z0-9_-]*\s*$`)
agentXMLOpenTagRE = regexp.MustCompile(`(?is)<\s*([A-Za-z_][A-Za-z0-9_.:-]*)\b[^>]*>`)
agentXMLCloseTagRE = regexp.MustCompile(`(?is)</\s*([A-Za-z_][A-Za-z0-9_.:\s-]*)\s*>`)
completedUseToolRE = regexp.MustCompile("(?is)completed.{0,300}?use\\s+(?:the\\s+)?`?([A-Za-z_][A-Za-z0-9_.:-]*)`?\\s+tool")
xmlToolWithResultREPattern = `(?is)<\s*%s\b[^>]*>\s*<\s*([A-Za-z_][A-Za-z0-9_.:-]*)\b`
)
func normalizeStrictAgentContent(content string) string {
cleaned := strings.TrimSpace(content)
cleaned = agentThinkingBlockRE.ReplaceAllString(cleaned, "")
cleaned = agentCodeFenceLineRE.ReplaceAllString(cleaned, "")
cleaned = strings.TrimSpace(cleaned)
for strings.HasPrefix(cleaned, "---") {
cleaned = strings.TrimSpace(strings.TrimPrefix(cleaned, "---"))
}
cleaned = strings.TrimSpace(cleaned)
if block, ok := firstXMLRootBlock(cleaned); ok {
return block
}
return cleaned
}
func startsWithXMLRoot(content string) bool {
trimmed := strings.TrimSpace(content)
open := agentXMLOpenTagRE.FindStringIndex(trimmed)
return open != nil && open[0] == 0
}
func inferXMLCompletionContract(prompt string) (string, string) {
tool := ""
if match := completedUseToolRE.FindStringSubmatch(prompt); len(match) == 2 {
tool = match[1]
}
if tool == "" {
return "", ""
}
resultTag := inferXMLResultTag(prompt, tool)
if resultTag == "" {
resultTag = "result"
}
return tool, resultTag
}
func inferXMLResultTag(prompt, tool string) string {
toolPattern := regexp.QuoteMeta(tool)
re := regexp.MustCompile(fmt.Sprintf(xmlToolWithResultREPattern, toolPattern))
if match := re.FindStringSubmatch(prompt); len(match) == 2 {
return match[1]
}
return ""
}
func wrapXMLCompletion(policy strictOutputPolicy, content string) string {
resultTag := policy.XMLResultTag
if resultTag == "" {
resultTag = "result"
}
return "<" + policy.XMLCompletionTool + ">\n<" + resultTag + ">" + html.EscapeString(strings.TrimSpace(content)) + "</" + resultTag + ">\n</" + policy.XMLCompletionTool + ">"
}
func firstXMLRootBlock(content string) (string, bool) {
open := agentXMLOpenTagRE.FindStringSubmatchIndex(content)
if open == nil {
return "", false
}
root := content[open[2]:open[3]]
search := content[open[1]:]
for _, close := range agentXMLCloseTagRE.FindAllStringSubmatchIndex(search, -1) {
name := search[close[2]:close[3]]
if canonicalXMLName(name) != canonicalXMLName(root) {
continue
}
closeStart := open[1] + close[0]
block := content[open[0]:closeStart] + "</" + root + ">"
return strings.TrimSpace(block), true
}
return "", false
}
func canonicalXMLName(name string) string {
var b strings.Builder
for _, r := range strings.ToLower(name) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
return b.String()
}
func chatMessagesInput(messages []chatMessage) []any {
out := make([]any, 0, len(messages))
for _, msg := range messages {
m := map[string]any{
"role": msg.Role,
"content": msg.Content,
}
if msg.ReasoningContent != "" {
m["thinking"] = msg.ReasoningContent
}
if len(msg.Images) > 0 {
m["images"] = msg.Images
}
if len(msg.ToolCalls) > 0 {
m["tool_calls"] = msg.ToolCalls
}
if msg.ToolName != "" {
m["tool_name"] = msg.ToolName
}
out = append(out, m)
}
return out
}
func contentToString(v any) string {
switch t := v.(type) {
case string:
return t
case []any:
parts := make([]string, 0, len(t))
for _, item := range t {
if m, ok := item.(map[string]any); ok {
if text, ok := m["text"].(string); ok {
parts = append(parts, text)
}
}
}
return strings.Join(parts, "\n")
default:
b, _ := json.Marshal(t)
return string(b)
}
}
func previewString(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
return s[:max] + "...[truncated]"
}
func mapKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
type chatCompletionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []chatCompletionChoice `json:"choices"`
Usage *openAIUsage `json:"usage,omitempty"`
}
type chatCompletionChoice struct {
Index int `json:"index"`
Message chatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type chatCompletionChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []chatCompletionChunkChoice `json:"choices"`
}
type chatCompletionChunkChoice struct {
Index int `json:"index"`
Delta chatDelta `json:"delta"`
FinishReason string `json:"finish_reason,omitempty"`
}
type chatDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
}
type openAIUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type openAIModel struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
}
type openAIModelsResponse struct {
Object string `json:"object"`
Data []openAIModel `json:"data"`
}
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Type string `json:"type"`
Message string `json:"message"`
}

View file

@ -0,0 +1,196 @@
package openai
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"go.uber.org/zap"
edgeservice "iop/apps/edge/internal/service"
)
func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle, outputPolicy strictOutputPolicy) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming_not_supported", "response writer does not support streaming")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
created := time.Now().Unix()
id := "chatcmpl-" + handle.RunID
model := responseModel(req.Model, handle.Target)
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{Role: "assistant"},
}},
})
if outputPolicy.Strict && outputPolicy.StreamBuffer {
s.streamBufferedChatCompletion(w, r, handle, id, created, model, flusher, outputPolicy)
return
}
var contentBuilder strings.Builder
var reasoningBuilder strings.Builder
defer func() {
s.logger.Info("openai chat completion stream closed",
zap.String("run_id", handle.RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.Int("content_len", contentBuilder.Len()),
zap.Int("reasoning_len", reasoningBuilder.Len()),
zap.String("content_preview", previewString(contentBuilder.String(), 1000)),
zap.String("reasoning_preview", previewString(reasoningBuilder.String(), 1000)),
)
}()
for {
select {
case <-r.Context().Done():
return
case nodeEvent := <-handle.NodeEvents:
if edgeservice.IsNodeDisconnected(nodeEvent) {
writeSSEError(w, flusher, "node disconnected")
return
}
case event := <-handle.Events:
if event == nil {
continue
}
switch event.GetType() {
case "delta":
if event.GetDelta() == "" {
continue
}
contentBuilder.WriteString(event.GetDelta())
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{Content: event.GetDelta()},
}},
})
case "reasoning_delta":
if event.GetDelta() == "" {
continue
}
reasoningBuilder.WriteString(event.GetDelta())
if outputPolicy.Strict {
continue
}
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{ReasoningContent: event.GetDelta()},
}},
})
case "complete":
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{},
FinishReason: "stop",
}},
})
fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
return
case "error", "cancelled":
msg := event.GetError()
if msg == "" {
msg = event.GetMessage()
}
if msg == "" {
msg = "run failed"
}
writeSSEError(w, flusher, msg)
return
}
case <-time.After(handle.WaitTimeout()):
writeSSEError(w, flusher, "run timed out")
return
}
}
}
func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Request, handle *edgeservice.RunHandle, id string, created int64, model string, flusher http.Flusher, outputPolicy strictOutputPolicy) {
text, reasoning, _, err := collectRunResult(r.Context(), handle)
if err != nil {
writeSSEError(w, flusher, err.Error())
return
}
text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning)
s.logger.Info("openai chat completion stream closed",
zap.String("run_id", handle.RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
zap.Bool("normalized", normalized),
zap.Int("content_len", len(text)),
zap.Int("reasoning_len", len(reasoning)),
zap.String("content_preview", previewString(text, 1000)),
zap.String("reasoning_preview", previewString(reasoning, 1000)),
)
if text != "" {
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{Content: text},
}},
})
}
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{},
FinishReason: "stop",
}},
})
fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
}
func writeSSE(w http.ResponseWriter, flusher http.Flusher, v any) {
b, err := json.Marshal(v)
if err != nil {
return
}
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
}
func writeSSEError(w http.ResponseWriter, flusher http.Flusher, message string) {
writeSSE(w, flusher, errorResponse{Error: errorBody{Type: "run_error", Message: message}})
fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
}

View file

@ -0,0 +1,134 @@
package openai
import (
"fmt"
"html"
"regexp"
"strings"
)
func strictOutputContractInstruction(policy strictOutputPolicy) string {
if !policy.ContractInstruction {
return ""
}
resultTag := policy.XMLResultTag
if resultTag == "" {
resultTag = "result"
}
return fmt.Sprintf("Strict output contract: follow the client's XML tool protocol. When you complete the task, output exactly one <%s> block and no text outside that XML block. The <%s> content must be the direct final answer that should be shown to the end user. Do not summarize that you answered, completed, explained, greeted, or performed the task. Do not write phrases like \"I completed\", \"I explained\", \"I greeted\", \"summary\", or \"main points\" unless the user explicitly asks for a summary. Put the actual user-facing response inside <%s>.", policy.XMLCompletionTool, resultTag, resultTag)
}
func prependSystemMessage(messages []chatMessage, content string) []chatMessage {
out := make([]chatMessage, 0, len(messages)+1)
out = append(out, chatMessage{Role: "system", Content: content})
out = append(out, messages...)
return out
}
func normalizeCompletionOutput(policy strictOutputPolicy, content, reasoning string) (string, string, bool) {
if !policy.Strict {
return content, reasoning, false
}
normalized := normalizeStrictAgentContent(content)
if strings.TrimSpace(normalized) == "" && strings.TrimSpace(reasoning) != "" {
normalized = normalizeStrictAgentContent(reasoning)
}
if strings.TrimSpace(normalized) == "" {
normalized = "Model returned no assistant content."
}
if policy.XMLCompletionTool != "" && !startsWithXMLRoot(normalized) {
normalized = wrapXMLCompletion(policy, normalized)
}
return normalized, "", normalized != content || reasoning != ""
}
var (
agentThinkingBlockRE = regexp.MustCompile(`(?is)<think(?:ing)?\b[^>]*>.*?</think(?:ing)?\s*>`)
agentCodeFenceLineRE = regexp.MustCompile(`(?m)^\s*` + "```" + `[A-Za-z0-9_-]*\s*$`)
agentXMLOpenTagRE = regexp.MustCompile(`(?is)<\s*([A-Za-z_][A-Za-z0-9_.:-]*)\b[^>]*>`)
agentXMLCloseTagRE = regexp.MustCompile(`(?is)</\s*([A-Za-z_][A-Za-z0-9_.:\s-]*)\s*>`)
completedUseToolRE = regexp.MustCompile("(?is)completed.{0,300}?use\\s+(?:the\\s+)?`?([A-Za-z_][A-Za-z0-9_.:-]*)`?\\s+tool")
xmlToolWithResultREPattern = `(?is)<\s*%s\b[^>]*>\s*<\s*([A-Za-z_][A-Za-z0-9_.:-]*)\b`
)
func normalizeStrictAgentContent(content string) string {
cleaned := strings.TrimSpace(content)
cleaned = agentThinkingBlockRE.ReplaceAllString(cleaned, "")
cleaned = agentCodeFenceLineRE.ReplaceAllString(cleaned, "")
cleaned = strings.TrimSpace(cleaned)
for strings.HasPrefix(cleaned, "---") {
cleaned = strings.TrimSpace(strings.TrimPrefix(cleaned, "---"))
}
cleaned = strings.TrimSpace(cleaned)
if block, ok := firstXMLRootBlock(cleaned); ok {
return block
}
return cleaned
}
func startsWithXMLRoot(content string) bool {
trimmed := strings.TrimSpace(content)
open := agentXMLOpenTagRE.FindStringIndex(trimmed)
return open != nil && open[0] == 0
}
func inferXMLCompletionContract(prompt string) (string, string) {
tool := ""
if match := completedUseToolRE.FindStringSubmatch(prompt); len(match) == 2 {
tool = match[1]
}
if tool == "" {
return "", ""
}
resultTag := inferXMLResultTag(prompt, tool)
if resultTag == "" {
resultTag = "result"
}
return tool, resultTag
}
func inferXMLResultTag(prompt, tool string) string {
toolPattern := regexp.QuoteMeta(tool)
re := regexp.MustCompile(fmt.Sprintf(xmlToolWithResultREPattern, toolPattern))
if match := re.FindStringSubmatch(prompt); len(match) == 2 {
return match[1]
}
return ""
}
func wrapXMLCompletion(policy strictOutputPolicy, content string) string {
resultTag := policy.XMLResultTag
if resultTag == "" {
resultTag = "result"
}
return "<" + policy.XMLCompletionTool + ">\n<" + resultTag + ">" + html.EscapeString(strings.TrimSpace(content)) + "</" + resultTag + ">\n</" + policy.XMLCompletionTool + ">"
}
func firstXMLRootBlock(content string) (string, bool) {
open := agentXMLOpenTagRE.FindStringSubmatchIndex(content)
if open == nil {
return "", false
}
root := content[open[2]:open[3]]
search := content[open[1]:]
for _, close := range agentXMLCloseTagRE.FindAllStringSubmatchIndex(search, -1) {
name := search[close[2]:close[3]]
if canonicalXMLName(name) != canonicalXMLName(root) {
continue
}
closeStart := open[1] + close[0]
block := content[open[0]:closeStart] + "</" + root + ">"
return strings.TrimSpace(block), true
}
return "", false
}
func canonicalXMLName(name string) string {
var b strings.Builder
for _, r := range strings.ToLower(name) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
return b.String()
}

View file

@ -0,0 +1,201 @@
package openai
import (
"encoding/json"
"strings"
)
type chatCompletionRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
Options map[string]any `json:"options,omitempty"`
Format any `json:"format,omitempty"`
KeepAlive any `json:"keep_alive,omitempty"`
Think any `json:"think,omitempty"`
Tools []any `json:"tools,omitempty"`
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
Images []any `json:"images,omitempty"`
ToolCalls []any `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"`
}
func (m *chatMessage) UnmarshalJSON(b []byte) error {
var raw struct {
Role string `json:"role"`
Content any `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
Images []any `json:"images,omitempty"`
ToolCalls []any `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"`
}
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
m.Role = raw.Role
m.Content = contentToString(raw.Content)
m.ReasoningContent = raw.ReasoningContent
m.Images = raw.Images
m.ToolCalls = raw.ToolCalls
m.ToolName = raw.ToolName
return nil
}
func (req chatCompletionRequest) runInput(prompt string, messages []chatMessage, strictOutput bool) map[string]any {
input := map[string]any{
"prompt": prompt,
"messages": chatMessagesInput(messages),
}
if len(req.Options) > 0 {
input["options"] = req.Options
}
if req.Format != nil {
input["format"] = req.Format
}
if req.KeepAlive != nil {
input["keep_alive"] = req.KeepAlive
}
if req.Think != nil {
input["think"] = req.Think
} else if strictOutput {
input["think"] = false
}
if len(req.Tools) > 0 {
input["tools"] = req.Tools
}
return input
}
type strictOutputPolicy struct {
Strict bool
StreamBuffer bool
XMLCompletionTool string
XMLResultTag string
ContractInstruction bool
}
func chatMessagesInput(messages []chatMessage) []any {
out := make([]any, 0, len(messages))
for _, msg := range messages {
m := map[string]any{
"role": msg.Role,
"content": msg.Content,
}
if msg.ReasoningContent != "" {
m["thinking"] = msg.ReasoningContent
}
if len(msg.Images) > 0 {
m["images"] = msg.Images
}
if len(msg.ToolCalls) > 0 {
m["tool_calls"] = msg.ToolCalls
}
if msg.ToolName != "" {
m["tool_name"] = msg.ToolName
}
out = append(out, m)
}
return out
}
func contentToString(v any) string {
switch t := v.(type) {
case string:
return t
case []any:
parts := make([]string, 0, len(t))
for _, item := range t {
if m, ok := item.(map[string]any); ok {
if text, ok := m["text"].(string); ok {
parts = append(parts, text)
}
}
}
return strings.Join(parts, "\n")
default:
b, _ := json.Marshal(t)
return string(b)
}
}
func previewString(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
return s[:max] + "...[truncated]"
}
func mapKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
type chatCompletionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []chatCompletionChoice `json:"choices"`
Usage *openAIUsage `json:"usage,omitempty"`
}
type chatCompletionChoice struct {
Index int `json:"index"`
Message chatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type chatCompletionChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []chatCompletionChunkChoice `json:"choices"`
}
type chatCompletionChunkChoice struct {
Index int `json:"index"`
Delta chatDelta `json:"delta"`
FinishReason string `json:"finish_reason,omitempty"`
}
type chatDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
}
type openAIUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type openAIModel struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
}
type openAIModelsResponse struct {
Object string `json:"object"`
Data []openAIModel `json:"data"`
}
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Type string `json:"type"`
Message string `json:"message"`
}

View file

@ -0,0 +1,176 @@
package service
import (
"context"
"fmt"
"sort"
"strings"
"time"
iop "iop/proto/gen/iop"
)
func edgeCommandResponseFromNodeCommand(req *iop.EdgeCommandRequest, view NodeCommandView, err error) *iop.EdgeCommandResponse {
if err != nil {
return &iop.EdgeCommandResponse{
Status: "error",
Error: err.Error(),
}
}
keys := make([]string, 0, len(view.Result))
for k := range view.Result {
keys = append(keys, k)
}
sort.Strings(keys)
summaries := make([]string, 0, len(keys))
for _, k := range keys {
summaries = append(summaries, fmt.Sprintf("%s=%s", k, view.Result[k]))
}
summaryStr := fmt.Sprintf("NodeCommand %s completed: %s", req.GetParameters()["command"], strings.Join(summaries, ", "))
return &iop.EdgeCommandResponse{
Status: "completed",
Summary: summaryStr,
}
}
func unsupportedEdgeCommand(req *iop.EdgeCommandRequest, msg string) *iop.EdgeCommandResponse {
return &iop.EdgeCommandResponse{
Status: "unsupported",
Error: msg,
}
}
func (s *Service) ExecuteCommand(ctx context.Context, req *iop.EdgeCommandRequest, onEvent func(*iop.EdgeCommandEvent)) (*iop.EdgeCommandResponse, error) {
switch req.Operation {
case "health.check":
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "started",
Summary: "Starting health check",
OccurredAt: time.Now().UnixNano(),
})
status := s.HealthStatus()
summary := fmt.Sprintf("Edge health check OK: %d node(s), %d capability(ies)", len(status.Nodes), len(status.Capabilities))
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "completed",
Summary: summary,
OccurredAt: time.Now().UnixNano(),
})
return &iop.EdgeCommandResponse{
Status: "completed",
Summary: summary,
}, nil
case "agent.status":
if req.TargetSelector == "" {
return nil, fmt.Errorf("target_selector is required for agent.status")
}
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "started",
Summary: fmt.Sprintf("Resolving agent status for %s", req.TargetSelector),
OccurredAt: time.Now().UnixNano(),
})
entry, err := s.ResolveNode(req.TargetSelector)
if err != nil {
return &iop.EdgeCommandResponse{
Status: "error",
Error: err.Error(),
}, nil
}
snap := NodeEntrySnapshot(entry)
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "completed",
Summary: fmt.Sprintf("Resolved agent status for %s", req.TargetSelector),
OccurredAt: time.Now().UnixNano(),
})
return &iop.EdgeCommandResponse{
Status: "completed",
Summary: fmt.Sprintf("node ID=%s Alias=%s Kind=%s State=%s", snap.NodeID, snap.Alias, snap.AgentKind, snap.LifecycleState),
}, nil
case "agent.command":
cmdName := req.GetParameters()["command"]
if cmdName == "" {
return unsupportedEdgeCommand(req, "missing command parameter for agent.command"), nil
}
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "started",
Summary: fmt.Sprintf("Routing command %s to node %s", cmdName, req.TargetSelector),
OccurredAt: time.Now().UnixNano(),
})
entry, err := s.ResolveNode(req.TargetSelector)
if err != nil {
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "failed",
Summary: fmt.Sprintf("Command %s failed: %v", cmdName, err),
OccurredAt: time.Now().UnixNano(),
})
return &iop.EdgeCommandResponse{
Status: "error",
Error: err.Error(),
}, nil
}
if entry.Client == nil {
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "failed",
Summary: fmt.Sprintf("Command %s failed: node client not connected", cmdName),
OccurredAt: time.Now().UnixNano(),
})
return &iop.EdgeCommandResponse{
Status: "error",
Error: "node client not connected",
}, nil
}
switch cmdName {
case "capabilities":
view, err := s.Capabilities(ctx, NodeCommandRequestSpec{
NodeRef: req.TargetSelector,
Adapter: req.GetParameters()["adapter"],
Target: req.GetParameters()["target"],
SessionID: req.GetParameters()["session_id"],
TimeoutSec: 10,
})
var phase string
var summary string
if err != nil {
phase = "failed"
summary = fmt.Sprintf("Command %s failed: %v", cmdName, err)
} else {
phase = "completed"
summary = fmt.Sprintf("Command %s completed successfully", cmdName)
}
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: phase,
Summary: summary,
OccurredAt: time.Now().UnixNano(),
})
return edgeCommandResponseFromNodeCommand(req, view, err), nil
default:
return unsupportedEdgeCommand(req, fmt.Sprintf("unsupported agent.command command %q", cmdName)), nil
}
default:
return &iop.EdgeCommandResponse{
Status: "unsupported",
Error: fmt.Sprintf("unsupported operation %s", req.Operation),
}, nil
}
}

View file

@ -0,0 +1,205 @@
package service
import (
"context"
"fmt"
"strconv"
"time"
toki "git.toki-labs.com/toki/proto-socket/go"
iop "iop/proto/gen/iop"
)
type UsageStatusRequest struct {
NodeRef string
Adapter string
Target string
SessionID string
TimeoutSec int
}
// UsageStatusView is the surface-neutral DTO for the usage-status command.
// proto types are retained for the embedded AgentUsageStatus payload.
type UsageStatusView struct {
NodeID string
NodeLabel string
Target string
SessionID string
UsageStatus *iop.AgentUsageStatus
}
// UsageStatusResult is retained as an alias for backward compatibility.
type UsageStatusResult = UsageStatusView
func (s *Service) UsageStatus(_ context.Context, req UsageStatusRequest) (UsageStatusResult, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return UsageStatusResult{}, err
}
commandReq := BuildUsageStatusRequest(req.Adapter, req.Target, req.SessionID, req.TimeoutSec)
resp, err := toki.SendRequestTyped[*iop.NodeCommandRequest, *iop.NodeCommandResponse](
&entry.Client.Communicator,
commandReq,
StatusWaitTimeout(commandReq),
)
if err != nil {
return UsageStatusResult{}, fmt.Errorf("transport error: %w", err)
}
if resp.GetError() != "" {
return UsageStatusResult{}, fmt.Errorf("node reported error: %s", resp.GetError())
}
return UsageStatusResult{
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
Target: commandReq.GetTarget(),
SessionID: commandReq.GetSessionId(),
UsageStatus: resp.GetUsageStatus(),
}, nil
}
func BuildUsageStatusRequest(adapter, targetName, sessionID string, timeoutSec int) *iop.NodeCommandRequest {
return buildNodeCommandRequest(iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS, "status", adapter, targetName, sessionID, timeoutSec)
}
// BuildNodeCommandRequest builds a NodeCommandRequest for any supported type.
// idPrefix is used to namespace request_id (e.g. "status", "caps", "sessions").
func BuildNodeCommandRequest(cmdType iop.NodeCommandType, idPrefix, adapter, targetName, sessionID string, timeoutSec int) *iop.NodeCommandRequest {
return buildNodeCommandRequest(cmdType, idPrefix, adapter, targetName, sessionID, timeoutSec)
}
func buildNodeCommandRequest(cmdType iop.NodeCommandType, idPrefix, adapter, targetName, sessionID string, timeoutSec int) *iop.NodeCommandRequest {
if idPrefix == "" {
idPrefix = "cmd"
}
return &iop.NodeCommandRequest{
RequestId: fmt.Sprintf("%s-%d", idPrefix, time.Now().UnixNano()),
Type: cmdType,
Adapter: adapter,
Target: targetName,
SessionId: NormalizeSessionID(sessionID),
TimeoutSec: int32(normalizeTimeoutSec(timeoutSec)),
}
}
func StatusWaitTimeout(req *iop.NodeCommandRequest) time.Duration {
return time.Duration(normalizeTimeoutSec(int(req.GetTimeoutSec()))+5) * time.Second
}
// NodeCommandRequestSpec is the surface-neutral input for ops console node
// commands (capabilities, session_list, transport_status). UsageStatus keeps
// its own typed request shape.
type NodeCommandRequestSpec struct {
NodeRef string
Adapter string
Target string
SessionID string
TimeoutSec int
}
// NodeCommandView is the surface-neutral result for non-usage-status node
// commands. Result mirrors the proto map and is empty when the node returned
// no payload.
type NodeCommandView struct {
NodeID string
NodeLabel string
Adapter string
Target string
SessionID string
Type iop.NodeCommandType
Result map[string]string
}
// Capabilities dispatches a CAPABILITIES node command and returns the result map.
func (s *Service) Capabilities(_ context.Context, req NodeCommandRequestSpec) (NodeCommandView, error) {
return s.sendNodeCommand(req, iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES, "caps")
}
// SessionList dispatches a SESSION_LIST node command and returns the result map.
func (s *Service) SessionList(_ context.Context, req NodeCommandRequestSpec) (NodeCommandView, error) {
return s.sendNodeCommand(req, iop.NodeCommandType_NODE_COMMAND_TYPE_SESSION_LIST, "sessions")
}
// TransportStatus dispatches a TRANSPORT_STATUS node command and returns the result map.
func (s *Service) TransportStatus(_ context.Context, req NodeCommandRequestSpec) (NodeCommandView, error) {
return s.sendNodeCommand(req, iop.NodeCommandType_NODE_COMMAND_TYPE_TRANSPORT_STATUS, "transport")
}
type OllamaAPIRequest struct {
NodeRef string
Adapter string
Target string
Method string
Path string
Body string
TimeoutSec int
}
type OllamaAPIView struct {
StatusCode int
ContentType string
Body string
}
func (s *Service) OllamaAPI(_ context.Context, req OllamaAPIRequest) (OllamaAPIView, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return OllamaAPIView{}, err
}
commandReq := buildNodeCommandRequest(iop.NodeCommandType_NODE_COMMAND_TYPE_OLLAMA_API, "ollama", req.Adapter, req.Target, "", req.TimeoutSec)
commandReq.Metadata = map[string]string{
"ollama_method": req.Method,
"ollama_path": req.Path,
"ollama_body": req.Body,
}
resp, err := toki.SendRequestTyped[*iop.NodeCommandRequest, *iop.NodeCommandResponse](
&entry.Client.Communicator,
commandReq,
StatusWaitTimeout(commandReq),
)
if err != nil {
return OllamaAPIView{}, fmt.Errorf("transport error: %w", err)
}
if resp.GetError() != "" {
return OllamaAPIView{}, fmt.Errorf("node reported error: %s", resp.GetError())
}
statusCode, _ := strconv.Atoi(resp.GetResult()["status_code"])
if statusCode == 0 {
statusCode = 200
}
return OllamaAPIView{
StatusCode: statusCode,
ContentType: resp.GetResult()["content_type"],
Body: resp.GetResult()["body"],
}, nil
}
func (s *Service) sendNodeCommand(req NodeCommandRequestSpec, cmdType iop.NodeCommandType, idPrefix string) (NodeCommandView, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return NodeCommandView{}, err
}
commandReq := buildNodeCommandRequest(cmdType, idPrefix, req.Adapter, req.Target, req.SessionID, req.TimeoutSec)
resp, err := toki.SendRequestTyped[*iop.NodeCommandRequest, *iop.NodeCommandResponse](
&entry.Client.Communicator,
commandReq,
StatusWaitTimeout(commandReq),
)
if err != nil {
return NodeCommandView{}, fmt.Errorf("transport error: %w", err)
}
if resp.GetError() != "" {
return NodeCommandView{}, fmt.Errorf("node reported error: %s", resp.GetError())
}
return NodeCommandView{
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
Adapter: commandReq.GetAdapter(),
Target: commandReq.GetTarget(),
SessionID: commandReq.GetSessionId(),
Type: resp.GetType(),
Result: resp.GetResult(),
}, nil
}

View file

@ -0,0 +1,244 @@
package service
import (
"context"
"fmt"
"time"
"google.golang.org/protobuf/types/known/structpb"
eventpkg "iop/packages/go/events"
iop "iop/proto/gen/iop"
)
type SubmitRunRequest struct {
NodeRef string
RunID string
Adapter string
Target string
SessionID string
Prompt string
Input map[string]any
Background bool
TimeoutSec int
Metadata map[string]string
}
// RunDispatch describes a dispatched run in surface-neutral terms. It is the
// metadata any caller (console, HTTP, future RPC) needs after submission.
type RunDispatch struct {
RunID string
NodeID string
NodeLabel string
Adapter string
Target string
SessionID string
Background bool
TimeoutSec int
}
// RunStream carries asynchronous events for a dispatched foreground run.
// Background runs leave both channels nil.
type RunStream struct {
Events <-chan *iop.RunEvent
NodeEvents <-chan *iop.EdgeNodeEvent
}
// RunHandle is the legacy combined surface kept for existing callers; it
// embeds the surface-neutral DTOs so HTTP/API code can consume RunDispatch
// or RunStream directly without depending on this struct.
type RunHandle struct {
RunDispatch
RunStream
close func()
}
func (h *RunHandle) Close() {
if h != nil && h.close != nil {
h.close()
}
}
func (h *RunHandle) WaitTimeout() time.Duration {
if h == nil {
return time.Duration(DefaultTimeoutSec+5) * time.Second
}
return time.Duration(normalizeTimeoutSec(h.TimeoutSec)+5) * time.Second
}
func (s *Service) SubmitRun(_ context.Context, req SubmitRunRequest) (*RunHandle, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return nil, err
}
runReq, runID, err := BuildRunRequest(req)
if err != nil {
return nil, err
}
var runEvents <-chan *iop.RunEvent
var unregisterRun func()
var nodeEvents <-chan *iop.EdgeNodeEvent
var unregisterNode func()
if !runReq.GetBackground() {
if s.events == nil {
return nil, fmt.Errorf("event bus is not configured")
}
runEvents, unregisterRun = s.events.SubscribeRun(runID, 4096)
nodeEvents, unregisterNode = s.events.SubscribeNode(entry.NodeID, 16)
}
if err := entry.Client.Send(runReq); err != nil {
if unregisterRun != nil {
unregisterRun()
}
if unregisterNode != nil {
unregisterNode()
}
return nil, err
}
return &RunHandle{
RunDispatch: RunDispatch{
RunID: runID,
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
Adapter: runReq.GetAdapter(),
Target: runReq.GetTarget(),
SessionID: runReq.GetSessionId(),
Background: runReq.GetBackground(),
TimeoutSec: int(runReq.GetTimeoutSec()),
},
RunStream: RunStream{
Events: runEvents,
NodeEvents: nodeEvents,
},
close: func() {
if unregisterRun != nil {
unregisterRun()
}
if unregisterNode != nil {
unregisterNode()
}
},
}, nil
}
type CancelRunRequest struct {
NodeRef string
RunID string
Adapter string
Target string
SessionID string
}
func BuildCancelRunRequest(req CancelRunRequest) *iop.CancelRequest {
return &iop.CancelRequest{
RunId: req.RunID,
Adapter: req.Adapter,
Target: req.Target,
SessionId: NormalizeSessionID(req.SessionID),
Action: iop.CancelAction_CANCEL_ACTION_CANCEL_RUN,
}
}
func (s *Service) CancelRun(_ context.Context, req CancelRunRequest) (CommandResult, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return CommandResult{}, err
}
cancelReq := BuildCancelRunRequest(req)
if err := entry.Client.Send(cancelReq); err != nil {
return CommandResult{}, err
}
return CommandResult{
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
SessionID: cancelReq.GetSessionId(),
}, nil
}
type TerminateSessionRequest struct {
NodeRef string
Adapter string
Target string
SessionID string
}
// CommandResult is the surface-neutral acknowledgement for one-shot node
// commands (terminate-session, future control RPCs).
type CommandResult struct {
NodeID string
NodeLabel string
SessionID string
}
// TerminateSessionResult is retained as an alias for backward compatibility.
type TerminateSessionResult = CommandResult
func (s *Service) TerminateSession(_ context.Context, req TerminateSessionRequest) (TerminateSessionResult, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return TerminateSessionResult{}, err
}
sessionID := NormalizeSessionID(req.SessionID)
cancelReq := &iop.CancelRequest{
Adapter: req.Adapter,
Target: req.Target,
SessionId: sessionID,
Action: iop.CancelAction_CANCEL_ACTION_TERMINATE_SESSION,
}
if err := entry.Client.Send(cancelReq); err != nil {
return TerminateSessionResult{}, err
}
return TerminateSessionResult{
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
SessionID: sessionID,
}, nil
}
func NewRunID() string {
return fmt.Sprintf("manual-%d", time.Now().UnixNano())
}
func IsNodeDisconnected(event *iop.EdgeNodeEvent) bool {
return event.GetType() == eventpkg.TypeNodeDisconnected
}
func BuildRunRequest(req SubmitRunRequest) (*iop.RunRequest, string, error) {
inputMap := make(map[string]any, len(req.Input)+1)
for k, v := range req.Input {
inputMap[k] = v
}
if req.Prompt != "" {
if _, ok := inputMap["prompt"]; !ok {
inputMap["prompt"] = req.Prompt
}
}
input, err := structpb.NewStruct(inputMap)
if err != nil {
return nil, "", err
}
runID := req.RunID
if runID == "" {
runID = NewRunID()
}
metadata := make(map[string]string, len(req.Metadata))
for k, v := range req.Metadata {
metadata[k] = v
}
return &iop.RunRequest{
RunId: runID,
Adapter: req.Adapter,
Target: req.Target,
SessionId: NormalizeSessionID(req.SessionID),
SessionMode: iop.RunSessionMode_RUN_SESSION_MODE_CREATE_IF_MISSING,
Background: req.Background,
Input: input,
TimeoutSec: int32(normalizeTimeoutSec(req.TimeoutSec)),
Metadata: metadata,
}, runID, nil
}

View file

@ -1,19 +1,8 @@
package service
import (
"context"
"fmt"
"strconv"
"strings"
"time"
toki "git.toki-labs.com/toki/proto-socket/go"
"google.golang.org/protobuf/types/known/structpb"
edgeevents "iop/apps/edge/internal/events"
edgenode "iop/apps/edge/internal/node"
eventpkg "iop/packages/go/events"
iop "iop/proto/gen/iop"
)
const (
@ -21,6 +10,12 @@ const (
DefaultTimeoutSec = 30
)
// Service is the surface-neutral application service shared by the console,
// HTTP/A2A input surfaces, and the Control Plane connector. Its responsibilities
// are split across this package by concern: status_provider.go (node snapshots,
// capabilities, health status), run_dispatch.go (run lifecycle), node_command.go
// (node command transport), and control_command.go (Control Plane command
// execution).
type Service struct {
registry *edgenode.Registry
events *edgeevents.Bus
@ -39,453 +34,7 @@ func (s *Service) ListNodes() []*edgenode.NodeEntry {
return s.registry.All()
}
// NodeSnapshot is a surface-neutral view of a registered node, suitable for
// CLI, HTTP, or other transports. Label is the short display label (node0, node1).
// AgentKind and LifecycleState carry the registry entry's generic node
// classification and lifecycle.
type NodeSnapshot struct {
NodeID string
Alias string
Label string
AgentKind string
LifecycleState string
Config *iop.NodeConfigPayload
}
// ListNodeSnapshots returns surface-neutral node descriptors derived from the
// current registry contents.
func (s *Service) ListNodeSnapshots() []NodeSnapshot {
entries := s.registry.All()
out := make([]NodeSnapshot, 0, len(entries))
for _, entry := range entries {
snap := NodeSnapshot{
NodeID: entry.NodeID,
Alias: entry.Alias,
Label: nodeLabel(entry),
AgentKind: entry.AgentKind,
LifecycleState: entry.LifecycleState,
}
if s.nodeStore != nil {
if rec, ok := s.nodeStore.FindByID(entry.NodeID); ok {
if payload, err := edgenode.BuildConfigPayload(rec); err == nil {
snap.Config = payload
}
}
}
out = append(out, snap)
}
return out
}
// NodeEntrySnapshot converts a registry entry into the surface-neutral DTO.
func NodeEntrySnapshot(entry *edgenode.NodeEntry) NodeSnapshot {
if entry == nil {
return NodeSnapshot{}
}
return NodeSnapshot{
NodeID: entry.NodeID,
Alias: entry.Alias,
Label: nodeLabel(entry),
AgentKind: entry.AgentKind,
LifecycleState: entry.LifecycleState,
}
}
func (s *Service) ResolveNode(ref string) (*edgenode.NodeEntry, error) {
return s.registry.Resolve(ref)
}
// ResolveNodeSnapshot returns a surface-neutral DTO for the resolved node.
func (s *Service) ResolveNodeSnapshot(ref string) (NodeSnapshot, error) {
entry, err := s.ResolveNode(ref)
if err != nil {
return NodeSnapshot{}, err
}
return NodeEntrySnapshot(entry), nil
}
type SubmitRunRequest struct {
NodeRef string
RunID string
Adapter string
Target string
SessionID string
Prompt string
Input map[string]any
Background bool
TimeoutSec int
Metadata map[string]string
}
// RunDispatch describes a dispatched run in surface-neutral terms. It is the
// metadata any caller (console, HTTP, future RPC) needs after submission.
type RunDispatch struct {
RunID string
NodeID string
NodeLabel string
Adapter string
Target string
SessionID string
Background bool
TimeoutSec int
}
// RunStream carries asynchronous events for a dispatched foreground run.
// Background runs leave both channels nil.
type RunStream struct {
Events <-chan *iop.RunEvent
NodeEvents <-chan *iop.EdgeNodeEvent
}
// RunHandle is the legacy combined surface kept for existing callers; it
// embeds the surface-neutral DTOs so HTTP/API code can consume RunDispatch
// or RunStream directly without depending on this struct.
type RunHandle struct {
RunDispatch
RunStream
close func()
}
func (h *RunHandle) Close() {
if h != nil && h.close != nil {
h.close()
}
}
func (h *RunHandle) WaitTimeout() time.Duration {
if h == nil {
return time.Duration(DefaultTimeoutSec+5) * time.Second
}
return time.Duration(normalizeTimeoutSec(h.TimeoutSec)+5) * time.Second
}
func (s *Service) SubmitRun(_ context.Context, req SubmitRunRequest) (*RunHandle, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return nil, err
}
runReq, runID, err := BuildRunRequest(req)
if err != nil {
return nil, err
}
var runEvents <-chan *iop.RunEvent
var unregisterRun func()
var nodeEvents <-chan *iop.EdgeNodeEvent
var unregisterNode func()
if !runReq.GetBackground() {
if s.events == nil {
return nil, fmt.Errorf("event bus is not configured")
}
runEvents, unregisterRun = s.events.SubscribeRun(runID, 4096)
nodeEvents, unregisterNode = s.events.SubscribeNode(entry.NodeID, 16)
}
if err := entry.Client.Send(runReq); err != nil {
if unregisterRun != nil {
unregisterRun()
}
if unregisterNode != nil {
unregisterNode()
}
return nil, err
}
return &RunHandle{
RunDispatch: RunDispatch{
RunID: runID,
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
Adapter: runReq.GetAdapter(),
Target: runReq.GetTarget(),
SessionID: runReq.GetSessionId(),
Background: runReq.GetBackground(),
TimeoutSec: int(runReq.GetTimeoutSec()),
},
RunStream: RunStream{
Events: runEvents,
NodeEvents: nodeEvents,
},
close: func() {
if unregisterRun != nil {
unregisterRun()
}
if unregisterNode != nil {
unregisterNode()
}
},
}, nil
}
type CancelRunRequest struct {
NodeRef string
RunID string
Adapter string
Target string
SessionID string
}
func BuildCancelRunRequest(req CancelRunRequest) *iop.CancelRequest {
return &iop.CancelRequest{
RunId: req.RunID,
Adapter: req.Adapter,
Target: req.Target,
SessionId: NormalizeSessionID(req.SessionID),
Action: iop.CancelAction_CANCEL_ACTION_CANCEL_RUN,
}
}
func (s *Service) CancelRun(_ context.Context, req CancelRunRequest) (CommandResult, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return CommandResult{}, err
}
cancelReq := BuildCancelRunRequest(req)
if err := entry.Client.Send(cancelReq); err != nil {
return CommandResult{}, err
}
return CommandResult{
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
SessionID: cancelReq.GetSessionId(),
}, nil
}
type TerminateSessionRequest struct {
NodeRef string
Adapter string
Target string
SessionID string
}
// CommandResult is the surface-neutral acknowledgement for one-shot node
// commands (terminate-session, future control RPCs).
type CommandResult struct {
NodeID string
NodeLabel string
SessionID string
}
// TerminateSessionResult is retained as an alias for backward compatibility.
type TerminateSessionResult = CommandResult
func (s *Service) TerminateSession(_ context.Context, req TerminateSessionRequest) (TerminateSessionResult, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return TerminateSessionResult{}, err
}
sessionID := NormalizeSessionID(req.SessionID)
cancelReq := &iop.CancelRequest{
Adapter: req.Adapter,
Target: req.Target,
SessionId: sessionID,
Action: iop.CancelAction_CANCEL_ACTION_TERMINATE_SESSION,
}
if err := entry.Client.Send(cancelReq); err != nil {
return TerminateSessionResult{}, err
}
return TerminateSessionResult{
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
SessionID: sessionID,
}, nil
}
type UsageStatusRequest struct {
NodeRef string
Adapter string
Target string
SessionID string
TimeoutSec int
}
// UsageStatusView is the surface-neutral DTO for the usage-status command.
// proto types are retained for the embedded AgentUsageStatus payload.
type UsageStatusView struct {
NodeID string
NodeLabel string
Target string
SessionID string
UsageStatus *iop.AgentUsageStatus
}
// UsageStatusResult is retained as an alias for backward compatibility.
type UsageStatusResult = UsageStatusView
func (s *Service) UsageStatus(_ context.Context, req UsageStatusRequest) (UsageStatusResult, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return UsageStatusResult{}, err
}
commandReq := BuildUsageStatusRequest(req.Adapter, req.Target, req.SessionID, req.TimeoutSec)
resp, err := toki.SendRequestTyped[*iop.NodeCommandRequest, *iop.NodeCommandResponse](
&entry.Client.Communicator,
commandReq,
StatusWaitTimeout(commandReq),
)
if err != nil {
return UsageStatusResult{}, fmt.Errorf("transport error: %w", err)
}
if resp.GetError() != "" {
return UsageStatusResult{}, fmt.Errorf("node reported error: %s", resp.GetError())
}
return UsageStatusResult{
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
Target: commandReq.GetTarget(),
SessionID: commandReq.GetSessionId(),
UsageStatus: resp.GetUsageStatus(),
}, nil
}
func BuildUsageStatusRequest(adapter, targetName, sessionID string, timeoutSec int) *iop.NodeCommandRequest {
return buildNodeCommandRequest(iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS, "status", adapter, targetName, sessionID, timeoutSec)
}
// BuildNodeCommandRequest builds a NodeCommandRequest for any supported type.
// idPrefix is used to namespace request_id (e.g. "status", "caps", "sessions").
func BuildNodeCommandRequest(cmdType iop.NodeCommandType, idPrefix, adapter, targetName, sessionID string, timeoutSec int) *iop.NodeCommandRequest {
return buildNodeCommandRequest(cmdType, idPrefix, adapter, targetName, sessionID, timeoutSec)
}
func buildNodeCommandRequest(cmdType iop.NodeCommandType, idPrefix, adapter, targetName, sessionID string, timeoutSec int) *iop.NodeCommandRequest {
if idPrefix == "" {
idPrefix = "cmd"
}
return &iop.NodeCommandRequest{
RequestId: fmt.Sprintf("%s-%d", idPrefix, time.Now().UnixNano()),
Type: cmdType,
Adapter: adapter,
Target: targetName,
SessionId: NormalizeSessionID(sessionID),
TimeoutSec: int32(normalizeTimeoutSec(timeoutSec)),
}
}
func StatusWaitTimeout(req *iop.NodeCommandRequest) time.Duration {
return time.Duration(normalizeTimeoutSec(int(req.GetTimeoutSec()))+5) * time.Second
}
// NodeCommandRequestSpec is the surface-neutral input for ops console node
// commands (capabilities, session_list, transport_status). UsageStatus keeps
// its own typed request shape.
type NodeCommandRequestSpec struct {
NodeRef string
Adapter string
Target string
SessionID string
TimeoutSec int
}
// NodeCommandView is the surface-neutral result for non-usage-status node
// commands. Result mirrors the proto map and is empty when the node returned
// no payload.
type NodeCommandView struct {
NodeID string
NodeLabel string
Adapter string
Target string
SessionID string
Type iop.NodeCommandType
Result map[string]string
}
// Capabilities dispatches a CAPABILITIES node command and returns the result map.
func (s *Service) Capabilities(_ context.Context, req NodeCommandRequestSpec) (NodeCommandView, error) {
return s.sendNodeCommand(req, iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES, "caps")
}
// SessionList dispatches a SESSION_LIST node command and returns the result map.
func (s *Service) SessionList(_ context.Context, req NodeCommandRequestSpec) (NodeCommandView, error) {
return s.sendNodeCommand(req, iop.NodeCommandType_NODE_COMMAND_TYPE_SESSION_LIST, "sessions")
}
// TransportStatus dispatches a TRANSPORT_STATUS node command and returns the result map.
func (s *Service) TransportStatus(_ context.Context, req NodeCommandRequestSpec) (NodeCommandView, error) {
return s.sendNodeCommand(req, iop.NodeCommandType_NODE_COMMAND_TYPE_TRANSPORT_STATUS, "transport")
}
type OllamaAPIRequest struct {
NodeRef string
Adapter string
Target string
Method string
Path string
Body string
TimeoutSec int
}
type OllamaAPIView struct {
StatusCode int
ContentType string
Body string
}
func (s *Service) OllamaAPI(_ context.Context, req OllamaAPIRequest) (OllamaAPIView, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return OllamaAPIView{}, err
}
commandReq := buildNodeCommandRequest(iop.NodeCommandType_NODE_COMMAND_TYPE_OLLAMA_API, "ollama", req.Adapter, req.Target, "", req.TimeoutSec)
commandReq.Metadata = map[string]string{
"ollama_method": req.Method,
"ollama_path": req.Path,
"ollama_body": req.Body,
}
resp, err := toki.SendRequestTyped[*iop.NodeCommandRequest, *iop.NodeCommandResponse](
&entry.Client.Communicator,
commandReq,
StatusWaitTimeout(commandReq),
)
if err != nil {
return OllamaAPIView{}, fmt.Errorf("transport error: %w", err)
}
if resp.GetError() != "" {
return OllamaAPIView{}, fmt.Errorf("node reported error: %s", resp.GetError())
}
statusCode, _ := strconv.Atoi(resp.GetResult()["status_code"])
if statusCode == 0 {
statusCode = 200
}
return OllamaAPIView{
StatusCode: statusCode,
ContentType: resp.GetResult()["content_type"],
Body: resp.GetResult()["body"],
}, nil
}
func (s *Service) sendNodeCommand(req NodeCommandRequestSpec, cmdType iop.NodeCommandType, idPrefix string) (NodeCommandView, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return NodeCommandView{}, err
}
commandReq := buildNodeCommandRequest(cmdType, idPrefix, req.Adapter, req.Target, req.SessionID, req.TimeoutSec)
resp, err := toki.SendRequestTyped[*iop.NodeCommandRequest, *iop.NodeCommandResponse](
&entry.Client.Communicator,
commandReq,
StatusWaitTimeout(commandReq),
)
if err != nil {
return NodeCommandView{}, fmt.Errorf("transport error: %w", err)
}
if resp.GetError() != "" {
return NodeCommandView{}, fmt.Errorf("node reported error: %s", resp.GetError())
}
return NodeCommandView{
NodeID: entry.NodeID,
NodeLabel: nodeLabel(entry),
Adapter: commandReq.GetAdapter(),
Target: commandReq.GetTarget(),
SessionID: commandReq.GetSessionId(),
Type: resp.GetType(),
Result: resp.GetResult(),
}, nil
}
// NormalizeSessionID maps an empty session id to the default session.
func NormalizeSessionID(id string) string {
if id == "" {
return DefaultSessionID
@ -493,49 +42,6 @@ func NormalizeSessionID(id string) string {
return id
}
func NewRunID() string {
return fmt.Sprintf("manual-%d", time.Now().UnixNano())
}
func IsNodeDisconnected(event *iop.EdgeNodeEvent) bool {
return event.GetType() == eventpkg.TypeNodeDisconnected
}
func BuildRunRequest(req SubmitRunRequest) (*iop.RunRequest, string, error) {
inputMap := make(map[string]any, len(req.Input)+1)
for k, v := range req.Input {
inputMap[k] = v
}
if req.Prompt != "" {
if _, ok := inputMap["prompt"]; !ok {
inputMap["prompt"] = req.Prompt
}
}
input, err := structpb.NewStruct(inputMap)
if err != nil {
return nil, "", err
}
runID := req.RunID
if runID == "" {
runID = NewRunID()
}
metadata := make(map[string]string, len(req.Metadata))
for k, v := range req.Metadata {
metadata[k] = v
}
return &iop.RunRequest{
RunId: runID,
Adapter: req.Adapter,
Target: req.Target,
SessionId: NormalizeSessionID(req.SessionID),
SessionMode: iop.RunSessionMode_RUN_SESSION_MODE_CREATE_IF_MISSING,
Background: req.Background,
Input: input,
TimeoutSec: int32(normalizeTimeoutSec(req.TimeoutSec)),
Metadata: metadata,
}, runID, nil
}
func normalizeTimeoutSec(timeoutSec int) int {
if timeoutSec <= 0 {
return DefaultTimeoutSec
@ -546,196 +52,3 @@ func normalizeTimeoutSec(timeoutSec int) int {
func nodeLabel(entry *edgenode.NodeEntry) string {
return entry.DisplayLabel()
}
func (s *Service) GetCapabilities() []*iop.EdgeCapabilitySummary {
var entries []*edgenode.NodeEntry
if s.registry != nil {
entries = s.registry.All()
}
if len(entries) == 0 {
return nil
}
return []*iop.EdgeCapabilitySummary{
{
Kind: "run",
Available: true,
Status: "ready",
Summary: "Generic execution node available",
},
}
}
func (s *Service) GetDomainAgents() []*iop.EdgeDomainAgentSummary {
return nil
}
func edgeCommandResponseFromNodeCommand(req *iop.EdgeCommandRequest, view NodeCommandView, err error) *iop.EdgeCommandResponse {
if err != nil {
return &iop.EdgeCommandResponse{
Status: "error",
Error: err.Error(),
}
}
var summaries []string
var keys []string
for k := range view.Result {
keys = append(keys, k)
}
importSort := func(a, b string) bool { return a < b }
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if importSort(keys[j], keys[i]) {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
for _, k := range keys {
summaries = append(summaries, fmt.Sprintf("%s=%s", k, view.Result[k]))
}
summaryStr := fmt.Sprintf("NodeCommand %s completed: %s", req.GetParameters()["command"], strings.Join(summaries, ", "))
return &iop.EdgeCommandResponse{
Status: "completed",
Summary: summaryStr,
}
}
func unsupportedEdgeCommand(req *iop.EdgeCommandRequest, msg string) *iop.EdgeCommandResponse {
return &iop.EdgeCommandResponse{
Status: "unsupported",
Error: msg,
}
}
func (s *Service) ExecuteCommand(ctx context.Context, req *iop.EdgeCommandRequest, onEvent func(*iop.EdgeCommandEvent)) (*iop.EdgeCommandResponse, error) {
switch req.Operation {
case "health.check":
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "started",
Summary: "Starting health check",
OccurredAt: time.Now().UnixNano(),
})
time.Sleep(10 * time.Millisecond) // simulation
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "completed",
Summary: "Health check passed",
OccurredAt: time.Now().UnixNano(),
})
return &iop.EdgeCommandResponse{
Status: "completed",
Summary: "Edge health check OK",
}, nil
case "agent.status":
if req.TargetSelector == "" {
return nil, fmt.Errorf("target_selector is required for agent.status")
}
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "started",
Summary: fmt.Sprintf("Resolving agent status for %s", req.TargetSelector),
OccurredAt: time.Now().UnixNano(),
})
entry, err := s.ResolveNode(req.TargetSelector)
if err != nil {
return &iop.EdgeCommandResponse{
Status: "error",
Error: err.Error(),
}, nil
}
snap := NodeEntrySnapshot(entry)
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "completed",
Summary: fmt.Sprintf("Resolved agent status for %s", req.TargetSelector),
OccurredAt: time.Now().UnixNano(),
})
return &iop.EdgeCommandResponse{
Status: "completed",
Summary: fmt.Sprintf("node ID=%s Alias=%s Kind=%s State=%s", snap.NodeID, snap.Alias, snap.AgentKind, snap.LifecycleState),
}, nil
case "agent.command":
cmdName := req.GetParameters()["command"]
if cmdName == "" {
return unsupportedEdgeCommand(req, "missing command parameter for agent.command"), nil
}
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "started",
Summary: fmt.Sprintf("Routing command %s to node %s", cmdName, req.TargetSelector),
OccurredAt: time.Now().UnixNano(),
})
entry, err := s.ResolveNode(req.TargetSelector)
if err != nil {
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "failed",
Summary: fmt.Sprintf("Command %s failed: %v", cmdName, err),
OccurredAt: time.Now().UnixNano(),
})
return &iop.EdgeCommandResponse{
Status: "error",
Error: err.Error(),
}, nil
}
if entry.Client == nil {
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: "failed",
Summary: fmt.Sprintf("Command %s failed: node client not connected", cmdName),
OccurredAt: time.Now().UnixNano(),
})
return &iop.EdgeCommandResponse{
Status: "error",
Error: "node client not connected",
}, nil
}
switch cmdName {
case "capabilities":
view, err := s.Capabilities(ctx, NodeCommandRequestSpec{
NodeRef: req.TargetSelector,
Adapter: req.GetParameters()["adapter"],
Target: req.GetParameters()["target"],
SessionID: req.GetParameters()["session_id"],
TimeoutSec: 10,
})
var phase string
var summary string
if err != nil {
phase = "failed"
summary = fmt.Sprintf("Command %s failed: %v", cmdName, err)
} else {
phase = "completed"
summary = fmt.Sprintf("Command %s completed successfully", cmdName)
}
onEvent(&iop.EdgeCommandEvent{
CommandId: req.CommandId,
Phase: phase,
Summary: summary,
OccurredAt: time.Now().UnixNano(),
})
return edgeCommandResponseFromNodeCommand(req, view, err), nil
default:
return unsupportedEdgeCommand(req, fmt.Sprintf("unsupported agent.command command %q", cmdName)), nil
}
default:
return &iop.EdgeCommandResponse{
Status: "unsupported",
Error: fmt.Sprintf("unsupported operation %s", req.Operation),
}, nil
}
}

View file

@ -559,6 +559,59 @@ func TestServiceExecuteCommand(t *testing.T) {
})
}
func TestServiceExecuteCommandHealthCheckDerivesFromStatus(t *testing.T) {
t.Run("with nodes summarizes registry and capabilities", func(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-1", AgentKind: config.AgentKindGenericNode})
reg.Register(&edgenode.NodeEntry{NodeID: "node-2", AgentKind: config.AgentKindGenericNode})
svc := edgeservice.New(reg, nil)
// HealthStatus is the Edge-owned boundary the command must read from.
status := svc.HealthStatus()
if len(status.Nodes) != 2 {
t.Fatalf("HealthStatus nodes: got %d want 2", len(status.Nodes))
}
if len(status.Capabilities) != 1 {
t.Fatalf("HealthStatus capabilities: got %d want 1", len(status.Capabilities))
}
var events []*iop.EdgeCommandEvent
resp, err := svc.ExecuteCommand(context.Background(), &iop.EdgeCommandRequest{
CommandId: "cmd-hc",
Operation: "health.check",
}, func(e *iop.EdgeCommandEvent) { events = append(events, e) })
if err != nil {
t.Fatalf("ExecuteCommand error: %v", err)
}
if resp.Status != "completed" {
t.Errorf("status: got %q want completed", resp.Status)
}
if !strings.Contains(resp.Summary, "2 node(s)") || !strings.Contains(resp.Summary, "1 capability(ies)") {
t.Errorf("summary not derived from status: got %q", resp.Summary)
}
if len(events) != 2 || events[1].Phase != "completed" {
t.Fatalf("unexpected events: %+v", events)
}
if events[1].Summary != resp.Summary {
t.Errorf("completed event summary %q != response summary %q", events[1].Summary, resp.Summary)
}
})
t.Run("without nodes reports empty status", func(t *testing.T) {
svc := edgeservice.New(edgenode.NewRegistry(), nil)
resp, err := svc.ExecuteCommand(context.Background(), &iop.EdgeCommandRequest{
CommandId: "cmd-hc",
Operation: "health.check",
}, func(*iop.EdgeCommandEvent) {})
if err != nil {
t.Fatalf("ExecuteCommand error: %v", err)
}
if !strings.Contains(resp.Summary, "0 node(s)") || !strings.Contains(resp.Summary, "0 capability(ies)") {
t.Errorf("empty status summary wrong: got %q", resp.Summary)
}
})
}
func TestServiceExecuteCommandAgentCommandCapabilitiesSuccess(t *testing.T) {
edgeConn, nodeConn := net.Pipe()
defer edgeConn.Close()

View file

@ -0,0 +1,111 @@
package service
import (
edgenode "iop/apps/edge/internal/node"
iop "iop/proto/gen/iop"
)
// NodeSnapshot is a surface-neutral view of a registered node, suitable for
// CLI, HTTP, or other transports. Label is the short display label (node0, node1).
// AgentKind and LifecycleState carry the registry entry's generic node
// classification and lifecycle.
type NodeSnapshot struct {
NodeID string
Alias string
Label string
AgentKind string
LifecycleState string
Config *iop.NodeConfigPayload
}
// ListNodeSnapshots returns surface-neutral node descriptors derived from the
// current registry contents.
func (s *Service) ListNodeSnapshots() []NodeSnapshot {
entries := s.registry.All()
out := make([]NodeSnapshot, 0, len(entries))
for _, entry := range entries {
snap := NodeSnapshot{
NodeID: entry.NodeID,
Alias: entry.Alias,
Label: nodeLabel(entry),
AgentKind: entry.AgentKind,
LifecycleState: entry.LifecycleState,
}
if s.nodeStore != nil {
if rec, ok := s.nodeStore.FindByID(entry.NodeID); ok {
if payload, err := edgenode.BuildConfigPayload(rec); err == nil {
snap.Config = payload
}
}
}
out = append(out, snap)
}
return out
}
// NodeEntrySnapshot converts a registry entry into the surface-neutral DTO.
func NodeEntrySnapshot(entry *edgenode.NodeEntry) NodeSnapshot {
if entry == nil {
return NodeSnapshot{}
}
return NodeSnapshot{
NodeID: entry.NodeID,
Alias: entry.Alias,
Label: nodeLabel(entry),
AgentKind: entry.AgentKind,
LifecycleState: entry.LifecycleState,
}
}
func (s *Service) ResolveNode(ref string) (*edgenode.NodeEntry, error) {
return s.registry.Resolve(ref)
}
// ResolveNodeSnapshot returns a surface-neutral DTO for the resolved node.
func (s *Service) ResolveNodeSnapshot(ref string) (NodeSnapshot, error) {
entry, err := s.ResolveNode(ref)
if err != nil {
return NodeSnapshot{}, err
}
return NodeEntrySnapshot(entry), nil
}
func (s *Service) GetCapabilities() []*iop.EdgeCapabilitySummary {
var entries []*edgenode.NodeEntry
if s.registry != nil {
entries = s.registry.All()
}
if len(entries) == 0 {
return nil
}
return []*iop.EdgeCapabilitySummary{
{
Kind: "run",
Available: true,
Status: "ready",
Summary: "Generic execution node available",
},
}
}
func (s *Service) GetDomainAgents() []*iop.EdgeDomainAgentSummary {
return nil
}
// HealthStatusView is the Edge-owned status summary used by control commands
// such as health.check. It is derived from the live node registry and
// capability snapshot rather than a fixed simulation delay.
type HealthStatusView struct {
Nodes []NodeSnapshot
Capabilities []*iop.EdgeCapabilitySummary
}
// HealthStatus returns a status summary derived from the current node registry
// and capability snapshot. It is the real Edge-owned boundary that backs the
// health.check control command.
func (s *Service) HealthStatus() HealthStatusView {
return HealthStatusView{
Nodes: s.ListNodeSnapshots(),
Capabilities: s.GetCapabilities(),
}
}