apps/node 중심 구현 — TCP+JSON transport, Hexagonal Architecture, mock/cli adapter, fx DI, SQLite 실행 이력 저장. edge/control-plane/worker는 cobra placeholder. 유닛 테스트 및 통합 테스트 클라이언트 계획서 추가.
345 lines
9.1 KiB
Markdown
345 lines
9.1 KiB
Markdown
<!-- task=node_testclient plan=0 tag=TEST -->
|
|
|
|
# iop-node-testclient — 통합 테스트 클라이언트
|
|
|
|
## 이 파일을 읽는 구현 에이전트에게
|
|
|
|
각 체크리스트 항목을 완료한 뒤 `[x]`로 표시하세요.
|
|
중간 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md` 검증 결과 섹션에 붙여 넣으세요.
|
|
계획과 다르게 구현한 부분은 반드시 `계획 대비 변경 사항`에 기록하세요.
|
|
|
|
## 배경
|
|
|
|
`iop-node serve` 가 실행 중일 때 실제 TCP 연결로 RunRequest를 전송하고
|
|
스트리밍 RunEvent를 수신하는 클라이언트가 없다.
|
|
unit test 만으로는 transport 계층의 end-to-end 동작을 확인할 수 없으므로,
|
|
수동 및 자동 통합 검증에 쓸 수 있는 `iop-node-testclient` 커맨드를 추가한다.
|
|
|
|
---
|
|
|
|
### [TEST-1] iop-node-testclient 커맨드 생성
|
|
|
|
**문제**
|
|
|
|
`apps/node/cmd/` 아래에 테스트 클라이언트가 없다.
|
|
현재 transport 계층
|
|
([apps/node/internal/transport/frame.go](apps/node/internal/transport/frame.go),
|
|
[apps/node/internal/transport/codec.go](apps/node/internal/transport/codec.go),
|
|
[apps/node/internal/transport/session.go:128-161](apps/node/internal/transport/session.go))
|
|
의 wire format은 `4-byte big-endian length | JSON(Envelope)` 이다.
|
|
클라이언트도 동일 프레이밍을 구현해야 한다.
|
|
|
|
**해결 방법**
|
|
|
|
`apps/node/cmd/iop-node-testclient/main.go` 를 신규 생성한다.
|
|
이미 `iop/packages/protocol` 에 모든 wire 타입이 정의되어 있으므로
|
|
프레임 읽기/쓰기는 `encoding/binary` + `encoding/json` 으로 직접 구현한다.
|
|
(transport 내부 패키지 unexported 함수 재사용 불가 — `internal` 경로)
|
|
|
|
커맨드 흐름:
|
|
|
|
```
|
|
1. -addr 플래그로 node 주소 수신 (기본 localhost:9090)
|
|
2. TCP Dial
|
|
3. (선택) -caps 플래그 시 CapabilityRequest 전송 → 응답 출력
|
|
4. RunRequest 전송 (adapter=mock, model=mock-echo, prompt="hello iop!")
|
|
5. RunEvent 스트림 수신 → 각 이벤트 출력
|
|
6. complete 또는 error 이벤트 수신 시 종료
|
|
```
|
|
|
|
전체 구현 코드:
|
|
|
|
```go
|
|
// apps/node/cmd/iop-node-testclient/main.go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"time"
|
|
|
|
"iop/packages/protocol"
|
|
)
|
|
|
|
func main() {
|
|
addr := flag.String("addr", "localhost:9090", "iop-node address")
|
|
caps := flag.Bool("caps", false, "send CapabilityRequest before RunRequest")
|
|
prompt := flag.String("prompt", "hello iop!", "prompt to send")
|
|
flag.Parse()
|
|
|
|
if err := run(*addr, *caps, *prompt); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(addr string, sendCaps bool, prompt string) error {
|
|
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
|
if err != nil {
|
|
return fmt.Errorf("dial %s: %w", addr, err)
|
|
}
|
|
defer conn.Close()
|
|
fmt.Printf("connected → %s\n", addr)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
if sendCaps {
|
|
if err := sendMsg(conn, protocol.TypeCapabilityRequest, "", protocol.CapabilityRequest{}); err != nil {
|
|
return fmt.Errorf("send capability request: %w", err)
|
|
}
|
|
fmt.Println("→ CapabilityRequest sent")
|
|
env, err := recvEnvelope(conn)
|
|
if err != nil {
|
|
return fmt.Errorf("recv capability response: %w", err)
|
|
}
|
|
if env.Type == protocol.TypeCapabilityResponse {
|
|
var resp protocol.CapabilityResponse
|
|
_ = json.Unmarshal(env.Payload, &resp)
|
|
fmt.Printf("← CapabilityResponse node_id=%q adapters=%d\n", resp.NodeID, len(resp.Adapters))
|
|
for _, a := range resp.Adapters {
|
|
fmt.Printf(" adapter=%q models=%v max_concurrency=%d\n", a.Name, a.Models, a.MaxConcurrency)
|
|
}
|
|
}
|
|
}
|
|
|
|
runID := fmt.Sprintf("testclient-%d", time.Now().UnixNano())
|
|
req := protocol.RunRequest{
|
|
RunID: runID,
|
|
Adapter: "mock",
|
|
Model: "mock-echo",
|
|
Input: map[string]any{"prompt": prompt},
|
|
}
|
|
if err := sendMsg(conn, protocol.TypeRunRequest, runID, req); err != nil {
|
|
return fmt.Errorf("send run request: %w", err)
|
|
}
|
|
fmt.Printf("→ RunRequest sent (run_id=%s)\n", runID)
|
|
|
|
return receiveEvents(ctx, conn)
|
|
}
|
|
|
|
func receiveEvents(ctx context.Context, conn net.Conn) error {
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return fmt.Errorf("context: %w", err)
|
|
}
|
|
|
|
env, err := recvEnvelope(conn)
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
fmt.Println("connection closed by server")
|
|
return nil
|
|
}
|
|
return fmt.Errorf("recv: %w", err)
|
|
}
|
|
|
|
switch env.Type {
|
|
case protocol.TypeRunEvent:
|
|
var e protocol.RunEvent
|
|
_ = json.Unmarshal(env.Payload, &e)
|
|
switch e.Type {
|
|
case "start":
|
|
fmt.Printf("← [start]\n")
|
|
case "delta":
|
|
fmt.Printf("%s", e.Delta)
|
|
case "complete":
|
|
fmt.Printf("\n← [complete] message=%q input_tokens=%d output_tokens=%d\n",
|
|
e.Message,
|
|
func() int {
|
|
if e.Usage != nil {
|
|
return e.Usage.InputTokens
|
|
}
|
|
return 0
|
|
}(),
|
|
func() int {
|
|
if e.Usage != nil {
|
|
return e.Usage.OutputTokens
|
|
}
|
|
return 0
|
|
}(),
|
|
)
|
|
return nil
|
|
case "error":
|
|
return fmt.Errorf("run error: %s", e.Error)
|
|
}
|
|
|
|
case protocol.TypeHeartbeat:
|
|
// 무시
|
|
|
|
case protocol.TypeError:
|
|
var e protocol.ErrorMsg
|
|
_ = json.Unmarshal(env.Payload, &e)
|
|
return fmt.Errorf("server error %s: %s", e.Code, e.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendMsg wraps payload in an Envelope and writes a length-prefixed frame.
|
|
func sendMsg(conn net.Conn, msgType protocol.MessageType, runID string, payload any) error {
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
env := protocol.Envelope{
|
|
ProtocolVersion: protocol.ProtocolVersion,
|
|
RequestID: fmt.Sprintf("req-%d", time.Now().UnixNano()),
|
|
RunID: runID,
|
|
Type: msgType,
|
|
Payload: data,
|
|
}
|
|
envBytes, err := json.Marshal(env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hdr := make([]byte, 4)
|
|
binary.BigEndian.PutUint32(hdr, uint32(len(envBytes)))
|
|
if _, err := conn.Write(hdr); err != nil {
|
|
return err
|
|
}
|
|
_, err = conn.Write(envBytes)
|
|
return err
|
|
}
|
|
|
|
// recvEnvelope reads one length-prefixed frame and decodes the Envelope.
|
|
func recvEnvelope(conn net.Conn) (*protocol.Envelope, error) {
|
|
hdr := make([]byte, 4)
|
|
if _, err := io.ReadFull(conn, hdr); err != nil {
|
|
return nil, err
|
|
}
|
|
length := binary.BigEndian.Uint32(hdr)
|
|
if length == 0 {
|
|
return nil, fmt.Errorf("received empty frame")
|
|
}
|
|
buf := make([]byte, length)
|
|
if _, err := io.ReadFull(conn, buf); err != nil {
|
|
return nil, err
|
|
}
|
|
var env protocol.Envelope
|
|
return &env, json.Unmarshal(buf, &env)
|
|
}
|
|
```
|
|
|
|
**수정 파일 및 체크리스트**
|
|
|
|
- [ ] `apps/node/cmd/iop-node-testclient/main.go` 신규 생성
|
|
- [ ] `-addr` 플래그 (기본 `localhost:9090`)
|
|
- [ ] `-caps` 플래그 (CapabilityRequest 선택 전송)
|
|
- [ ] `-prompt` 플래그 (기본 `"hello iop!"`)
|
|
- [ ] TCP Dial + 타임아웃 (5s)
|
|
- [ ] CapabilityRequest 전송 및 응답 출력 (`-caps` 시)
|
|
- [ ] RunRequest 전송 (mock adapter)
|
|
- [ ] RunEvent 스트림 수신 루프
|
|
- [ ] complete / error 시 정상 종료
|
|
- [ ] 60s 전체 타임아웃
|
|
|
|
**테스트 작성**
|
|
|
|
통합 테스트 클라이언트 자체는 수동/E2E 검증 도구이므로 별도 `_test.go` 파일은 작성하지 않는다.
|
|
단, `sendMsg` / `recvEnvelope` 함수는 `frame.go` + `codec.go` 유닛 테스트(node_unit_tests 태스크)로 간접 검증된다.
|
|
|
|
**중간 검증**
|
|
|
|
빌드 가능 여부 확인:
|
|
|
|
```bash
|
|
go build -o bin/iop-node-testclient ./apps/node/cmd/iop-node-testclient
|
|
```
|
|
|
|
기대 결과: 빌드 에러 없음
|
|
|
|
---
|
|
|
|
### [TEST-2] Makefile에 testclient 빌드 타겟 추가
|
|
|
|
**문제**
|
|
|
|
[Makefile:6-9](Makefile)의 `build` 타겟에 testclient가 없다.
|
|
|
|
**해결 방법**
|
|
|
|
```makefile
|
|
# Before (Makefile:7)
|
|
go build $(GOFLAGS) -o bin/iop-node ./apps/node/cmd/iop-node
|
|
|
|
# After
|
|
go build $(GOFLAGS) -o bin/iop-node ./apps/node/cmd/iop-node
|
|
go build $(GOFLAGS) -o bin/iop-node-testclient ./apps/node/cmd/iop-node-testclient
|
|
```
|
|
|
|
**수정 파일 및 체크리스트**
|
|
|
|
- [ ] `Makefile` — `build` 타겟에 `iop-node-testclient` 라인 추가
|
|
|
|
**테스트 작성**
|
|
|
|
Makefile 변경이므로 테스트 파일 불필요.
|
|
|
|
**중간 검증**
|
|
|
|
```bash
|
|
make build
|
|
ls -la bin/
|
|
```
|
|
|
|
기대 결과: `bin/iop-node-testclient` 파일 생성됨
|
|
|
|
---
|
|
|
|
## 수정 파일 요약
|
|
|
|
| 파일 | 항목 |
|
|
|------|------|
|
|
| `apps/node/cmd/iop-node-testclient/main.go` | TEST-1 |
|
|
| `Makefile` | TEST-2 |
|
|
|
|
## 최종 검증
|
|
|
|
두 개의 터미널이 필요하다.
|
|
|
|
**터미널 A — node 서버 실행:**
|
|
|
|
```bash
|
|
go mod tidy
|
|
go build -o bin/iop-node ./apps/node/cmd/iop-node
|
|
./bin/iop-node serve --config configs/node.yaml
|
|
```
|
|
|
|
기대 결과: `transport listening addr=0.0.0.0:9090` 로그 출력
|
|
|
|
**터미널 B — testclient 실행:**
|
|
|
|
```bash
|
|
go build -o bin/iop-node-testclient ./apps/node/cmd/iop-node-testclient
|
|
|
|
# 기본 RunRequest
|
|
./bin/iop-node-testclient
|
|
|
|
# CapabilityRequest 포함
|
|
./bin/iop-node-testclient -caps
|
|
|
|
# 커스텀 프롬프트
|
|
./bin/iop-node-testclient -prompt "안녕하세요 IOP!"
|
|
```
|
|
|
|
기대 결과:
|
|
|
|
```
|
|
connected → localhost:9090
|
|
→ RunRequest sent (run_id=testclient-...)
|
|
← [start]
|
|
echo: hello iop!
|
|
← [complete] message="mock execution complete" input_tokens=2 output_tokens=4
|
|
```
|
|
|
|
**빌드 전체 확인:**
|
|
|
|
```bash
|
|
go build ./...
|
|
```
|