기능: IOP 모노레포 스캐폴드 초기 구현
apps/node 중심 구현 — TCP+JSON transport, Hexagonal Architecture, mock/cli adapter, fx DI, SQLite 실행 이력 저장. edge/control-plane/worker는 cobra placeholder. 유닛 테스트 및 통합 테스트 클라이언트 계획서 추가.
This commit is contained in:
commit
2d6fde0876
69 changed files with 4878 additions and 0 deletions
16
.clinerules
Normal file
16
.clinerules
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# 공통 규칙
|
||||
|
||||
- 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다.
|
||||
- 코드 변경 전 관련 domain rule을 먼저 확인한다.
|
||||
- 요청 범위를 넘는 변경을 하지 않는다.
|
||||
- 불확실하면 단정하지 말고 후보를 제시한다.
|
||||
|
||||
아래 요청은 `agent-ops/skills/common/router.md`를 읽고 수행한다.
|
||||
- agent-ops 초기화
|
||||
- domain rule 생성
|
||||
- skill 생성
|
||||
- git commit / git push
|
||||
- agent-ops 업데이트 / 진입 파일 재적용
|
||||
|
||||
`agent-ops/rules/project/rules.md`와
|
||||
`agent-ops/rules/private/rules.md`를 읽고 작업을 시작한다. 파일이 없을경우 무시한다.
|
||||
16
.cursorrules
Normal file
16
.cursorrules
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# 공통 규칙
|
||||
|
||||
- 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다.
|
||||
- 코드 변경 전 관련 domain rule을 먼저 확인한다.
|
||||
- 요청 범위를 넘는 변경을 하지 않는다.
|
||||
- 불확실하면 단정하지 말고 후보를 제시한다.
|
||||
|
||||
아래 요청은 `agent-ops/skills/common/router.md`를 읽고 수행한다.
|
||||
- agent-ops 초기화
|
||||
- domain rule 생성
|
||||
- skill 생성
|
||||
- git commit / git push
|
||||
- agent-ops 업데이트 / 진입 파일 재적용
|
||||
|
||||
`agent-ops/rules/project/rules.md`와
|
||||
`agent-ops/rules/private/rules.md`를 읽고 작업을 시작한다. 파일이 없을경우 무시한다.
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
# Agent-Ops Private Rules
|
||||
agent-ops/rules/private/
|
||||
16
AGENTS.md
Normal file
16
AGENTS.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# 공통 규칙
|
||||
|
||||
- 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다.
|
||||
- 코드 변경 전 관련 domain rule을 먼저 확인한다.
|
||||
- 요청 범위를 넘는 변경을 하지 않는다.
|
||||
- 불확실하면 단정하지 말고 후보를 제시한다.
|
||||
|
||||
아래 요청은 `agent-ops/skills/common/router.md`를 읽고 수행한다.
|
||||
- agent-ops 초기화
|
||||
- domain rule 생성
|
||||
- skill 생성
|
||||
- git commit / git push
|
||||
- agent-ops 업데이트 / 진입 파일 재적용
|
||||
|
||||
`agent-ops/rules/project/rules.md`와
|
||||
`agent-ops/rules/private/rules.md`를 읽고 작업을 시작한다. 파일이 없을경우 무시한다.
|
||||
16
CLAUDE.md
Normal file
16
CLAUDE.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# 공통 규칙
|
||||
|
||||
- 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다.
|
||||
- 코드 변경 전 관련 domain rule을 먼저 확인한다.
|
||||
- 요청 범위를 넘는 변경을 하지 않는다.
|
||||
- 불확실하면 단정하지 말고 후보를 제시한다.
|
||||
|
||||
아래 요청은 `agent-ops/skills/common/router.md`를 읽고 수행한다.
|
||||
- agent-ops 초기화
|
||||
- domain rule 생성
|
||||
- skill 생성
|
||||
- git commit / git push
|
||||
- agent-ops 업데이트 / 진입 파일 재적용
|
||||
|
||||
`agent-ops/rules/project/rules.md`와
|
||||
`agent-ops/rules/private/rules.md`를 읽고 작업을 시작한다. 파일이 없을경우 무시한다.
|
||||
16
GEMINI.md
Normal file
16
GEMINI.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# 공통 규칙
|
||||
|
||||
- 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다.
|
||||
- 코드 변경 전 관련 domain rule을 먼저 확인한다.
|
||||
- 요청 범위를 넘는 변경을 하지 않는다.
|
||||
- 불확실하면 단정하지 말고 후보를 제시한다.
|
||||
|
||||
아래 요청은 `agent-ops/skills/common/router.md`를 읽고 수행한다.
|
||||
- agent-ops 초기화
|
||||
- domain rule 생성
|
||||
- skill 생성
|
||||
- git commit / git push
|
||||
- agent-ops 업데이트 / 진입 파일 재적용
|
||||
|
||||
`agent-ops/rules/project/rules.md`와
|
||||
`agent-ops/rules/private/rules.md`를 읽고 작업을 시작한다. 파일이 없을경우 무시한다.
|
||||
31
Makefile
Normal file
31
Makefile
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
.PHONY: all build tidy test proto clean
|
||||
|
||||
GOFLAGS ?= -trimpath
|
||||
|
||||
all: build
|
||||
|
||||
build:
|
||||
go build $(GOFLAGS) -o bin/iop-node ./apps/node/cmd/iop-node
|
||||
go build $(GOFLAGS) -o bin/iop-edge ./apps/edge/cmd/iop-edge
|
||||
go build $(GOFLAGS) -o bin/iop-control-plane ./apps/control-plane/cmd/iop-control-plane
|
||||
go build $(GOFLAGS) -o bin/iop-worker ./apps/worker/cmd/iop-worker
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
# Requires: protoc + protoc-gen-go (go install google.golang.org/protobuf/cmd/protoc-gen-go@latest)
|
||||
proto:
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--proto_path=. \
|
||||
proto/iop/runtime.proto \
|
||||
proto/iop/node.proto \
|
||||
proto/iop/control.proto \
|
||||
proto/iop/job.proto
|
||||
|
||||
clean:
|
||||
rm -rf bin/
|
||||
61
README.md
Normal file
61
README.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# IOP — Inference Operations Platform
|
||||
|
||||
IOP는 단일 서비스가 아닌 플랫폼이다. 여러 앱이 협력하여 AI 모델 추론 워크로드를 처리한다.
|
||||
|
||||
## 앱 구성
|
||||
|
||||
| 앱 | 설명 | 상태 |
|
||||
|---|---|---|
|
||||
| `apps/node` | 노드 에이전트. 디바이스당 1개 실행. 실제 추론 어댑터 관리 | **1차 구현 대상** |
|
||||
| `apps/edge` | 외부 OpenAI-compatible API 게이트웨이 | Placeholder |
|
||||
| `apps/control-plane` | 노드 등록/정책/스케줄링 제어 | Placeholder |
|
||||
| `apps/worker` | 비동기 작업 처리기 | Placeholder |
|
||||
|
||||
## 내부 통신
|
||||
|
||||
- **프로토콜**: TCP + Protobuf + mTLS
|
||||
- **프레이밍**: 4-byte big-endian length prefix | JSON(Envelope)
|
||||
- gRPC **사용 금지**
|
||||
- WebSocket transport 기본화 **금지**
|
||||
|
||||
## 외부 API
|
||||
|
||||
외부 OpenAI-compatible API는 향후 `apps/edge`에서 담당한다.
|
||||
|
||||
## 빌드
|
||||
|
||||
```bash
|
||||
# 의존성 설치
|
||||
go mod tidy
|
||||
|
||||
# node 빌드
|
||||
go build -o bin/iop-node ./apps/node/cmd/iop-node
|
||||
|
||||
# 실행
|
||||
./bin/iop-node version
|
||||
./bin/iop-node config print
|
||||
./bin/iop-node serve
|
||||
```
|
||||
|
||||
## 모노레포 구조
|
||||
|
||||
```
|
||||
iop/
|
||||
apps/
|
||||
node/ # Node Agent — 1차 구현
|
||||
edge/ # Edge API — placeholder
|
||||
control-plane/
|
||||
worker/
|
||||
packages/
|
||||
config/ # 설정 로딩
|
||||
protocol/ # 와이어 포맷 타입
|
||||
policy/ # 정책 엔진 인터페이스
|
||||
auth/ # mTLS 인증
|
||||
metadata/ # 메타데이터 타입
|
||||
jobs/ # 작업 큐 타입
|
||||
observability/ # 로깅/메트릭
|
||||
version/ # 버전 상수
|
||||
proto/iop/ # Protobuf 스펙 정의
|
||||
configs/ # 앱별 설정 파일
|
||||
docs/ # 문서
|
||||
```
|
||||
1
agent-ops/.version
Normal file
1
agent-ops/.version
Normal file
|
|
@ -0,0 +1 @@
|
|||
1.1.7
|
||||
29
agent-ops/rules/common/_templates/domain-rule-template.md
Normal file
29
agent-ops/rules/common/_templates/domain-rule-template.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# <domain-name>
|
||||
|
||||
## 목적 / 책임
|
||||
|
||||
<이 도메인이 담당하는 책임을 1~2문장으로>
|
||||
|
||||
## 포함 경로
|
||||
|
||||
- `<path>/` — <이 경로가 이 도메인에 속하는 이유>
|
||||
|
||||
## 제외 경로
|
||||
|
||||
- `<path>/` — <왜 이 도메인이 아닌지>
|
||||
|
||||
## 주요 구성 요소
|
||||
|
||||
- `<ClassName>` — <역할>
|
||||
|
||||
## 유지할 패턴
|
||||
|
||||
- <네이밍 규칙 또는 아키텍처 패턴>
|
||||
|
||||
## 다른 도메인과의 경계
|
||||
|
||||
- **<인접 domain>**: <어디까지가 이 도메인이고 어디서부터 저 도메인인지>
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- <이 도메인 코드에서 하면 안 되는 것>
|
||||
16
agent-ops/rules/common/rules.md
Normal file
16
agent-ops/rules/common/rules.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# 공통 규칙
|
||||
|
||||
- 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다.
|
||||
- 코드 변경 전 관련 domain rule을 먼저 확인한다.
|
||||
- 요청 범위를 넘는 변경을 하지 않는다.
|
||||
- 불확실하면 단정하지 말고 후보를 제시한다.
|
||||
|
||||
아래 요청은 `agent-ops/skills/common/router.md`를 읽고 수행한다.
|
||||
- agent-ops 초기화
|
||||
- domain rule 생성
|
||||
- skill 생성
|
||||
- git commit / git push
|
||||
- agent-ops 업데이트 / 진입 파일 재적용
|
||||
|
||||
`agent-ops/rules/project/rules.md`와
|
||||
`agent-ops/rules/private/rules.md`를 읽고 작업을 시작한다. 파일이 없을경우 무시한다.
|
||||
57
agent-ops/skills/common/_templates/skill-template.md
Normal file
57
agent-ops/skills/common/_templates/skill-template.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
name: <skill-name>
|
||||
version: 1.0.0
|
||||
description: <이 skill이 하는 일을 한 줄로 설명. 트리거 키워드 포함 권장>
|
||||
depends: [<other-skill>] # 선택 — 의존 skill이 없으면 이 줄 삭제
|
||||
---
|
||||
|
||||
# <skill-name>
|
||||
|
||||
## 목적
|
||||
|
||||
<이 skill이 해결하는 문제를 1~2문장으로 설명>
|
||||
|
||||
## 언제 호출할지
|
||||
|
||||
- <이 skill을 호출해야 하는 상황 1>
|
||||
- <이 skill을 호출해야 하는 상황 2>
|
||||
- <이 skill을 호출해야 하는 상황 3>
|
||||
|
||||
## 입력
|
||||
|
||||
- `<param-name>`: <설명> (필수)
|
||||
- `<param-name>`: <설명> (선택)
|
||||
|
||||
## 먼저 확인할 것
|
||||
|
||||
- [ ] <실행 전 반드시 확인해야 할 조건 1>
|
||||
- [ ] <실행 전 반드시 확인해야 할 조건 2>
|
||||
|
||||
## 실행 절차
|
||||
|
||||
1. **<단계명>**
|
||||
- <세부 행동>
|
||||
- <세부 행동>
|
||||
|
||||
2. **<단계명>**
|
||||
- <세부 행동>
|
||||
|
||||
3. **결과 보고**
|
||||
- <출력할 내용>
|
||||
|
||||
## 실행 결과 검증
|
||||
|
||||
- [ ] <실행 후 확인해야 할 성공 조건 1>
|
||||
- [ ] <실행 후 확인해야 할 성공 조건 2>
|
||||
- 검증 실패 시: <실패 시 취할 행동 — 롤백, 사용자 알림, 재시도 등>
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
<출력 예시>
|
||||
```
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- <절대 하면 안 되는 것>
|
||||
- <절대 하면 안 되는 것>
|
||||
218
agent-ops/skills/common/code-review/SKILL.md
Normal file
218
agent-ops/skills/common/code-review/SKILL.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
---
|
||||
name: code-review
|
||||
description: Review completed implementation work in the current repository. Reads PLAN.md and CODE_REVIEW.md from the active task, reviews the actual changed source files, appends a verdict to CODE_REVIEW.md, then archives both files as .log. On PASS, writes complete.log summarising the full loop history. If Required or Suggested issues are found (FAIL or WARN), writes a new PLAN.md and CODE_REVIEW.md stub so the loop continues immediately. Nit-only findings may still PASS.
|
||||
---
|
||||
|
||||
# Code Review
|
||||
|
||||
## Purpose
|
||||
|
||||
Review the implementation phase of the plan-code-review loop:
|
||||
|
||||
```text
|
||||
plan skill -> implementation -> code-review skill
|
||||
^ |
|
||||
+----- issues found: new PLAN.md
|
||||
```
|
||||
|
||||
## Workflow Contract
|
||||
|
||||
Active work must live at `agent-task/{task_name}/PLAN.md` and `agent-task/{task_name}/CODE_REVIEW.md`. These paths are the state protocol shared with the plan skill. Do not adapt them per repository unless the whole loop contract is intentionally changed in both skills.
|
||||
|
||||
Directory states:
|
||||
|
||||
| State | Meaning |
|
||||
|-------|---------|
|
||||
| `PLAN.md` + `CODE_REVIEW.md` | Ready for review |
|
||||
| `complete.log` + `*.log` files | Task complete (PASS) |
|
||||
| Only `*.log` files (no `complete.log`) | Task terminated mid-loop or abandoned |
|
||||
|
||||
The implementing agent never archives or deletes active files; archiving is this skill's responsibility.
|
||||
|
||||
## Step 1 - Find Active Task
|
||||
|
||||
Glob `agent-task/*/CODE_REVIEW.md`:
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Exactly one | Review that task; `PLAN.md` is expected beside it |
|
||||
| None | Nothing to review; stop and report |
|
||||
| Multiple | List paths and ask which task to review |
|
||||
|
||||
## Step 2 - Load Context
|
||||
|
||||
Count `agent-task/{task_name}/code_review_*.log`:
|
||||
|
||||
- `0`: first review. Read `CODE_REVIEW.md`, `PLAN.md`, every planned source file, related tests, and files importing/imported by changed files up to 2 levels deep.
|
||||
- `>=1`: follow-up review. Start with `git diff`, `git diff --cached`, and `git log --oneline -5`, then expand to related callers, implementers, tests, and any planned files missing from the diff.
|
||||
|
||||
The diff is the starting point, not the boundary. Follow behavior and API connections far enough to judge correctness.
|
||||
|
||||
## Step 3 - Pre-Review Checklist
|
||||
|
||||
Before writing the verdict:
|
||||
|
||||
- Compare actual source files against every planned checklist item.
|
||||
- Grep renamed/removed symbols for stale references.
|
||||
- Confirm every required test exists, name matches, and assertions are meaningful.
|
||||
- Cross-check claimed verification output in `CODE_REVIEW.md` against actual code and project commands.
|
||||
- For follow-up reviews, compare diff against the plan and scan for unplanned changes, debug prints, dead code, TODOs, formatting-only noise, and unrelated edits.
|
||||
|
||||
## Step 4 - Append Verdict
|
||||
|
||||
Append `코드리뷰 결과` to `CODE_REVIEW.md`.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `종합 판정`: exactly `PASS`, `WARN`, or `FAIL`.
|
||||
- `차원별 평가`: Pass/Warn/Fail for correctness, completeness, test coverage, API contract, code quality, plan deviation, verification trust.
|
||||
- `발견된 문제`: `없음`, or bullets using `Required`, `Suggested`, or `Nit` with `file:line` and a concrete fix.
|
||||
- `다음 단계`: keep only the matching PASS/WARN/FAIL line.
|
||||
|
||||
Severity semantics:
|
||||
|
||||
| Verdict | Meaning | Follow-up plan |
|
||||
|---------|---------|----------------|
|
||||
| `PASS` | No Required/Suggested issues. Nit-only findings may still PASS. | No |
|
||||
| `WARN` | One or more Suggested issues, zero Required. | Yes |
|
||||
| `FAIL` | One or more Required issues. | Yes |
|
||||
|
||||
Issue severity:
|
||||
|
||||
- `Required`: correctness, API contract, missing required test, or plan-completeness issue.
|
||||
- `Suggested`: useful improvement that should enter the loop but does not block correctness.
|
||||
- `Nit`: tiny cleanup; may be recorded without forcing WARN.
|
||||
|
||||
## Step 5 - Archive Active Files
|
||||
|
||||
Archive order is fixed:
|
||||
|
||||
1. Count existing `code_review_*.log` as `N`; rename `CODE_REVIEW.md` to `code_review_N.log`.
|
||||
2. Count existing `plan_*.log` as `M`; rename `PLAN.md` to `plan_M.log`.
|
||||
|
||||
After archiving, neither active `.md` file remains unless Step 6 writes a follow-up.
|
||||
|
||||
## Step 6 - Post-Review Actions
|
||||
|
||||
For `PASS`, write `agent-task/{task_name}/complete.log` before reporting:
|
||||
|
||||
Required fields in `complete.log`:
|
||||
- `완료 일시`: date completed.
|
||||
- `요약`: one-line task description and loop count.
|
||||
- `루프 이력`: table of plan/code_review log pairs with their verdict.
|
||||
- `최종 리뷰 요약`: bullet list of what was implemented.
|
||||
- `잔여 Nit`: any Nit-only findings recorded but not acted on (omit section if none).
|
||||
|
||||
Then report:
|
||||
|
||||
- Verdict.
|
||||
- Archive filenames.
|
||||
- `complete.log` written; task complete.
|
||||
|
||||
For `WARN` or `FAIL`, write a new `PLAN.md` and `CODE_REVIEW.md` stub using the plan skill format:
|
||||
|
||||
- New plan number is the count of `plan_*.log` after archive.
|
||||
- Header tag is `REVIEW_<PARENT_TAG>`.
|
||||
- `FAIL`: one plan item per Required issue.
|
||||
- `WARN`: one grouped plan item for Suggested issues, plus related Nit issues if useful.
|
||||
- Each plan item needs problem, solution with before/after when non-trivial, checklist, test decision, intermediate verification.
|
||||
|
||||
`CODE_REVIEW.md` stub template (fill `{…}` placeholders; everything else is fixed and must not be changed by the implementing agent):
|
||||
|
||||
```markdown
|
||||
<!-- task={task_name} plan={N} tag={TAG} -->
|
||||
|
||||
# Code Review Reference - {TAG}
|
||||
|
||||
## 개요
|
||||
|
||||
date={YYYY-MM-DD}
|
||||
task={task_name}, plan={N}, tag={TAG}
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
|
||||
|
||||
1. `CODE_REVIEW.md` → `code_review_N.log` (N = 기존 code_review_*.log 수)
|
||||
2. `PLAN.md` → `plan_M.log` (M = 기존 plan_*.log 수)
|
||||
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [{TAG}-1] {item description} | [ ] |
|
||||
| [{TAG}-2] {item description} | [ ] |
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
{pre-filled from plan — one bullet per review focus area}
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
### {TAG}-1 중간 검증
|
||||
```
|
||||
$ {verification command from plan}
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```
|
||||
$ {final verification command from plan}
|
||||
(output)
|
||||
```
|
||||
```
|
||||
|
||||
Sections and their ownership:
|
||||
|
||||
| 섹션 | 소유자 | 설명 |
|
||||
|------|--------|------|
|
||||
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음 |
|
||||
| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` → `[x]` 체크만 구현 에이전트가 수행 |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 |
|
||||
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
|
||||
| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움 |
|
||||
| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 |
|
||||
|
||||
Report Required/Suggested counts, archive names, and the new plan path.
|
||||
|
||||
## Review Dimensions
|
||||
|
||||
| Dimension | Check |
|
||||
|-----------|-------|
|
||||
| Correctness | Logic, edge cases, concurrency, errors |
|
||||
| Completeness | All planned checklist items done |
|
||||
| Test coverage | Required tests present and meaningful |
|
||||
| API contract | Call sites, compatibility, docs |
|
||||
| Code quality | No debug prints, dead code, leftover TODOs |
|
||||
| Plan deviation | Deviations justified, no unrelated risk |
|
||||
| Verification trust | Reported output matches actual code |
|
||||
|
||||
## Quality Rules
|
||||
|
||||
- Lead with findings; use specific `file:line`.
|
||||
- Provide a concrete fix for every Required issue.
|
||||
- Name exact stale symbols or missing tests.
|
||||
- Do not write vague praise or style opinions without a rule.
|
||||
- Every dimension gets Pass/Warn/Fail.
|
||||
|
||||
## Final Checklist
|
||||
|
||||
- `code_review_N.log` exists with verdict appended.
|
||||
- `plan_M.log` exists.
|
||||
- No active `.md` files remain after PASS.
|
||||
- PASS: `complete.log` written with loop history, implementation summary, and residual Nits.
|
||||
- WARN/FAIL: new active `PLAN.md` and `CODE_REVIEW.md` created with matching headers; no `complete.log`.
|
||||
4
agent-ops/skills/common/code-review/agents/openai.yaml
Normal file
4
agent-ops/skills/common/code-review/agents/openai.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
interface:
|
||||
display_name: "Code Review"
|
||||
short_description: "Review task implementation"
|
||||
default_prompt: "Use $code-review to review the active repository task and archive or continue the loop."
|
||||
124
agent-ops/skills/common/commit-push/SKILL.md
Normal file
124
agent-ops/skills/common/commit-push/SKILL.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
---
|
||||
name: commit-push
|
||||
version: 1.0.0
|
||||
description: 변경 사항을 커밋하고 원격에 푸시한다. "커밋해줘", "푸시해줘", "커밋하고 푸시" 요청 시 사용한다.
|
||||
---
|
||||
|
||||
# commit-push
|
||||
|
||||
## 목적
|
||||
|
||||
현재 변경 내용을 분석하여 한국어 커밋 메시지를 작성하고, 커밋 및 푸시를 수행한다.
|
||||
|
||||
## 언제 호출할지
|
||||
|
||||
- "커밋해줘", "커밋하고 푸시해줘" 요청 시
|
||||
- "변경 사항 올려줘", "푸시해줘" 요청 시
|
||||
- 코드 작업 완료 후 "반영해줘", "올려줘" 요청 시
|
||||
|
||||
## 입력
|
||||
|
||||
- `scope`: 커밋 범위 — all(전체) 또는 특정 파일/경로 (선택, 기본값: all)
|
||||
- `push`: 푸시 여부 (선택, 기본값: true)
|
||||
|
||||
## 먼저 확인할 것
|
||||
|
||||
- [ ] 커밋할 변경 사항이 존재하는가 (`git status`로 확인)
|
||||
- [ ] 현재 브랜치가 올바른 브랜치인가
|
||||
- [ ] `.env`, 시크릿 파일 등 민감한 파일이 스테이징에 포함되어 있지 않은가
|
||||
|
||||
## 실행 절차
|
||||
|
||||
1. **변경 사항 파악**
|
||||
- `git status`로 변경/추가/삭제된 파일 목록을 확인한다
|
||||
- `git diff`로 구체적인 변경 내용을 확인한다
|
||||
- 변경 사항이 없으면 사용자에게 알리고 종료한다
|
||||
|
||||
2. **민감 파일 검사**
|
||||
- 스테이징 대상에 아래 패턴이 포함되면 해당 파일을 제외하고 사용자에게 경고한다
|
||||
- `.env`, `.env.*`
|
||||
- `*secret*`, `*credential*`, `*password*`
|
||||
- `*.pem`, `*.key`, `*.p12`
|
||||
- `.gitignore`에 이미 등록된 파일은 무시한다
|
||||
|
||||
3. **커밋 메시지 작성**
|
||||
- 변경 내용을 분석하여 한국어로 커밋 메시지를 작성한다
|
||||
- 아래 메시지 규칙을 따른다
|
||||
|
||||
4. **사용자 확인**
|
||||
- 커밋 메시지와 대상 파일 목록을 보여주고 승인을 받는다
|
||||
- 사용자가 메시지 수정을 요청하면 반영한다
|
||||
|
||||
5. **커밋 실행**
|
||||
- scope에 따라 파일을 스테이징한다
|
||||
- all: 변경된 전체 파일을 개별적으로 `git add`
|
||||
- 특정 경로: 해당 파일만 `git add`
|
||||
- 승인된 메시지로 `git commit`을 실행한다
|
||||
|
||||
6. **푸시 실행**
|
||||
- push가 true인 경우에만 실행한다
|
||||
- 현재 브랜치를 원격에 푸시한다
|
||||
- 원격 브랜치가 없으면 `-u origin {branch}`로 설정한다
|
||||
- 충돌 발생 시 사용자에게 알리고 중단한다
|
||||
|
||||
7. **결과 보고**
|
||||
- 커밋 해시, 메시지, 변경 파일 수, 푸시 결과를 출력한다
|
||||
|
||||
## 커밋 메시지 규칙
|
||||
|
||||
### 형식
|
||||
|
||||
```
|
||||
<타입>: <변경 요약>
|
||||
|
||||
<본문> (선택)
|
||||
```
|
||||
|
||||
### 타입 목록
|
||||
|
||||
| 타입 | 용도 |
|
||||
|------|------|
|
||||
| 기능 | 새로운 기능 추가 |
|
||||
| 수정 | 버그 수정 |
|
||||
| 개선 | 기존 기능 개선, 리팩토링 |
|
||||
| 문서 | 문서 추가/수정 |
|
||||
| 설정 | 빌드, CI/CD, 설정 파일 변경 |
|
||||
| 테스트 | 테스트 추가/수정 |
|
||||
| 정리 | 코드 정리, 불필요 코드 제거 |
|
||||
|
||||
### 작성 원칙
|
||||
|
||||
- **한국어**로 작성한다
|
||||
- 요약은 50자 이내로 간결하게 작성한다
|
||||
- 요약은 "~한다" 형태의 서술형으로 끝낸다 (예: "로그인 검증 로직을 추가한다")
|
||||
- 본문은 **무엇을** 변경했는지가 아니라 **왜** 변경했는지를 쓴다
|
||||
- 변경 파일이 3개 이하이면 본문을 생략할 수 있다
|
||||
- 여러 성격의 변경이 섞여 있으면 가장 핵심적인 변경을 기준으로 타입을 정한다
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 커밋 완료
|
||||
|
||||
- 브랜치: {브랜치명}
|
||||
- 커밋: {해시} — {커밋 메시지 요약}
|
||||
- 변경 파일: {n}개
|
||||
- {파일 경로}: {변경 유형}
|
||||
- 푸시: {성공/생략/실패}
|
||||
```
|
||||
|
||||
## 실행 결과 검증
|
||||
|
||||
- [ ] `git log -1`로 방금 생성한 커밋이 존재하고, 메시지가 승인된 내용과 일치하는가
|
||||
- [ ] 커밋에 포함된 파일 목록이 의도한 scope와 일치하는가 (`git diff-tree --no-commit-id --name-only -r HEAD`)
|
||||
- [ ] push가 true인 경우, `git status`에서 "Your branch is up to date" 또는 원격과 동기화 상태인가
|
||||
- 검증 실패 시: 실패 원인(커밋 누락, 푸시 실패 등)을 사용자에게 알리고 재시도 여부를 확인한다
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- `git add -A` 또는 `git add .`을 사용하지 않는다 (민감 파일 혼입 방지)
|
||||
- `git push --force`를 사용하지 않는다
|
||||
- 사용자 확인 없이 커밋하지 않는다
|
||||
- 영어로 커밋 메시지를 작성하지 않는다
|
||||
- 변경 내용과 무관한 커밋 메시지를 작성하지 않는다
|
||||
- `--no-verify` 옵션을 사용하지 않는다
|
||||
104
agent-ops/skills/common/create-domain-rule/SKILL.md
Normal file
104
agent-ops/skills/common/create-domain-rule/SKILL.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
---
|
||||
name: create-domain-rule
|
||||
version: 1.0.0
|
||||
description: 새로운 도메인 rules.md 파일을 생성하기 위한 범용 스킬
|
||||
---
|
||||
|
||||
# Create Domain Rule
|
||||
|
||||
## 목적
|
||||
|
||||
`agent-ops/rules/project/domain/<domain-name>/rules.md` 파일을 올바른 형식으로 생성한다.
|
||||
실제 프로젝트 폴더 구조를 분석하여 경로와 구성 요소를 자동으로 채운다.
|
||||
생성 후 `rules/project/rules.md`의 도메인 매핑 테이블에 항목을 추가한다.
|
||||
|
||||
## 언제 호출할지
|
||||
|
||||
- 새로운 도메인 영역의 코드를 처음 변경하기 전에
|
||||
- 기존 domain rule이 없는 경로에 대해 규칙이 필요할 때
|
||||
- 사용자가 특정 도메인의 rule 파일을 만들어 달라고 요청할 때
|
||||
- agent-ops 초기 scaffold 이후 도메인을 추가 확장할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- `domain-name`: 생성할 도메인 이름, kebab-case (필수)
|
||||
- `domain-type`: `core` / `supporting` / `generic` 중 하나 (필수)
|
||||
- `path-hints`: 이 도메인에 해당하는 경로 힌트 목록 (선택, 없으면 자동 탐색)
|
||||
|
||||
## 먼저 확인할 것
|
||||
|
||||
- [ ] `agent-ops/rules/project/domain/<domain-name>/rules.md` 가 이미 존재하는지 확인
|
||||
- [ ] `agent-ops/rules/common/_templates/domain-rule-template.md` 를 읽어 최신 템플릿 형식 파악
|
||||
- [ ] `agent-ops/rules/project/domain/` 하위 기존 domain rule 목록을 확인하여 중복·유사 도메인 여부 판단
|
||||
|
||||
## 실행 절차
|
||||
|
||||
1. **중복 확인**
|
||||
- `agent-ops/rules/project/domain/<domain-name>/` 폴더 존재 여부 확인
|
||||
- 책임이 겹치는 기존 domain rule 이 있으면 사용자에게 알리고 중단한다
|
||||
|
||||
2. **경로 탐색**
|
||||
- `path-hints` 가 제공된 경우: 해당 경로를 기준으로 폴더 구조 확인
|
||||
- `path-hints` 가 없는 경우: `domain-name` 과 연관된 폴더명·파일명으로 프로젝트를 탐색
|
||||
- 실제로 존재하는 경로만 포함 경로에 기재한다
|
||||
- 존재하지 않는 경로는 기재하지 않는다
|
||||
|
||||
3. **도메인 분석**
|
||||
- 탐색된 경로의 주요 파일·모듈을 읽어 아래를 도출한다
|
||||
- 도메인의 목적 / 책임 한 줄 요약
|
||||
- 포함 경로 목록
|
||||
- 제외 경로 (인접 도메인과 겹칠 수 있는 경로)
|
||||
- 주요 구성 요소 (파일, 모듈, 클래스, 함수 등)
|
||||
- 유지할 패턴 (네이밍 규칙, 아키텍처 패턴 등)
|
||||
- 다른 도메인과의 경계
|
||||
- 금지 사항
|
||||
|
||||
4. **도메인 rules.md 생성**
|
||||
- 경로: `agent-ops/rules/project/domain/<domain-name>/rules.md`
|
||||
- `domain-rule-template.md` 형식을 따른다
|
||||
- 실제 코드에서 확인된 내용만 기재한다
|
||||
- 불확실한 항목은 `<!-- TODO: 확인 필요 -->` 주석으로 남긴다
|
||||
|
||||
5. **rules/project/rules.md 업데이트**
|
||||
- `agent-ops/rules/project/rules.md` 를 읽는다
|
||||
- 도메인 매핑 테이블에 아래 형식으로 추가한다
|
||||
```
|
||||
| `<path-pattern>` | `agent-ops/rules/project/domain/<domain-name>/rules.md` |
|
||||
```
|
||||
- 경로 패턴은 실제 포함 경로를 기준으로 작성한다
|
||||
- 기존 테이블 항목을 삭제하거나 재정렬하지 않는다
|
||||
|
||||
6. **결과 보고**
|
||||
- 생성한 파일 경로
|
||||
- rules/project/rules.md 에 추가한 항목
|
||||
- 불확실하여 TODO로 남긴 항목 (해당 시)
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 생성 완료
|
||||
|
||||
- Domain Rule 경로: agent-ops/rules/project/domain/<domain-name>/rules.md
|
||||
- 도메인 유형: <core | supporting | generic>
|
||||
- rules/project/rules.md 추가 경로 패턴: <path-pattern>
|
||||
|
||||
## TODO 항목 (확인 필요)
|
||||
- <불확실하여 직접 확인이 필요한 항목> (해당 시)
|
||||
```
|
||||
|
||||
## 실행 결과 검증
|
||||
|
||||
- [ ] `agent-ops/rules/project/domain/<domain-name>/rules.md` 파일이 생성되었는가
|
||||
- [ ] 생성된 파일이 `domain-rule-template.md`의 모든 섹션(목적, 포함 경로, 제외 경로, 주요 구성 요소, 유지할 패턴, 경계, 금지 사항)을 포함하는가
|
||||
- [ ] `rules/project/rules.md`의 도메인 매핑 테이블에 해당 도메인 항목이 추가되었는가
|
||||
- [ ] 포함 경로에 기재된 경로가 실제로 프로젝트에 존재하는가
|
||||
- 검증 실패 시: 누락된 섹션 또는 잘못된 경로를 사용자에게 알리고 해당 부분만 보완한다
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- 이미 존재하는 rules.md 를 덮어쓰지 않는다
|
||||
- 실제 존재하지 않는 경로를 포함 경로에 넣지 않는다
|
||||
- 추측으로 패턴이나 금지 사항을 채우지 않는다 — 확인된 내용만 기재한다
|
||||
- rules/project/rules.md 의 기존 항목을 삭제하거나 재정렬하지 않는다
|
||||
- 하나의 domain rule 에 여러 독립 도메인의 책임을 묶지 않는다
|
||||
- 코드 변경을 수행하지 않는다 — 이 skill 은 rule 파일 생성만 담당한다
|
||||
95
agent-ops/skills/common/create-skill/SKILL.md
Normal file
95
agent-ops/skills/common/create-skill/SKILL.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
name: create-skill
|
||||
version: 1.0.0
|
||||
description: 새로운 SKILL.md 파일을 생성하기 위한 범용 스킬
|
||||
---
|
||||
|
||||
# Create Skill
|
||||
|
||||
## 목적
|
||||
|
||||
`agent-ops/skills/` 하위에 올바른 형식의 SKILL.md 파일을 생성한다.
|
||||
기존 skill-template.md 를 기반으로, 요청 목적에 맞는 내용을 채워 넣는다.
|
||||
생성 후 라우팅 항목을 추가한다.
|
||||
|
||||
### 생성 위치 결정
|
||||
- `.agent-ops-source` 파일이 **있으면** (공통 관리 레포): `agent-ops/skills/common/<skill-name>/SKILL.md`
|
||||
- `.agent-ops-source` 파일이 **없으면** (타겟 프로젝트): `agent-ops/skills/project/<skill-name>/SKILL.md`
|
||||
|
||||
## 언제 호출할지
|
||||
|
||||
- 새로운 반복 작업 패턴이 생겨 skill로 정의해야 할 때
|
||||
- 기존 skill이 없는 작업 유형을 처음 수행하기 전에
|
||||
- 사용자가 특정 작업을 skill로 만들어 달라고 요청할 때
|
||||
|
||||
## 입력
|
||||
|
||||
- `skill-name`: 생성할 skill 이름, kebab-case (필수)
|
||||
- `purpose`: 이 skill이 해결하는 문제 한 줄 요약 (필수)
|
||||
- `trigger-cases`: 이 skill을 호출해야 하는 상황 목록 (선택)
|
||||
|
||||
## 먼저 확인할 것
|
||||
|
||||
- [ ] `agent-ops/skills/common/` 및 `agent-ops/skills/project/` 하위에 동일 이름의 디렉터리가 이미 존재하는지 확인
|
||||
- [ ] `agent-ops/rules/common/rules.md` 및 `agent-ops/rules/project/rules.md` 에 이미 유사한 라우팅 항목이 있는지 확인
|
||||
- [ ] `agent-ops/skills/common/_templates/skill-template.md` 를 읽어 최신 템플릿 형식 파악
|
||||
|
||||
## 실행 절차
|
||||
|
||||
1. **중복 확인**
|
||||
- `agent-ops/skills/common/<skill-name>/` 및 `agent-ops/skills/project/<skill-name>/` 폴더 존재 여부 확인
|
||||
- 기능이 겹치는 기존 skill이 있으면 사용자에게 알리고 중단한다
|
||||
|
||||
2. **목적 분석**
|
||||
- `purpose` 와 `trigger-cases` 를 바탕으로 아래 항목을 도출한다
|
||||
- 언제 호출할지 (2~4개)
|
||||
- 필요한 입력 파라미터
|
||||
- 사전 확인 항목
|
||||
- 실행 절차 (3~7단계)
|
||||
- 출력 형식
|
||||
- 금지 사항
|
||||
|
||||
3. **SKILL.md 생성**
|
||||
- 경로: 생성 위치 결정 규칙에 따라 `common/` 또는 `project/` 하위에 생성
|
||||
- `skill-template.md` 형식을 따른다
|
||||
- 프로젝트 특화 내용보다 범용 절차를 우선한다
|
||||
- 절차는 구체적이되 지나치게 세부 구현을 기술하지 않는다
|
||||
|
||||
4. **라우팅 업데이트**
|
||||
- `.agent-ops-source` 마커가 **있으면** (공통 관리 레포): `agent-ops/rules/common/rules.md`에 라우팅 항목 추가
|
||||
- `.agent-ops-source` 마커가 **없으면** (타겟 프로젝트): `agent-ops/rules/project/rules.md`의 프로젝트 스킬 라우터 섹션에 라우팅 항목 추가
|
||||
- 이 skill이 속할 라우팅 축(구조 분석/코드 변경/흐름 추적 등)을 판단한다
|
||||
- 기존 라우팅 구조를 깨지 않는다
|
||||
|
||||
5. **결과 보고**
|
||||
- 생성한 파일 경로
|
||||
- 라우팅 항목을 추가한 파일과 내용
|
||||
- 이 skill이 다루지 않는 범위(필요 시)
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 생성 완료
|
||||
|
||||
- SKILL 경로: agent-ops/skills/{common|project}/<skill-name>/SKILL.md
|
||||
- 라우팅 추가: <대상 파일> → <라우팅 축> → <skill-name>
|
||||
|
||||
## 주의사항 (해당 시)
|
||||
- <이 skill이 다루지 않는 범위 또는 주의할 점>
|
||||
```
|
||||
|
||||
## 실행 결과 검증
|
||||
|
||||
- [ ] `agent-ops/skills/{common|project}/<skill-name>/SKILL.md` 파일이 생성되었는가
|
||||
- [ ] 생성된 파일이 `skill-template.md`의 필수 섹션(목적, 언제 호출할지, 실행 절차, 실행 결과 검증, 출력 형식, 금지 사항)을 포함하는가
|
||||
- [ ] frontmatter에 name, version, description이 올바르게 기재되었는가
|
||||
- [ ] 라우팅 대상 파일에 해당 스킬의 라우팅 항목이 추가되었는가
|
||||
- 검증 실패 시: 누락된 섹션 또는 라우팅 항목을 사용자에게 알리고 해당 부분만 보완한다
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- 이미 존재하는 skill 을 덮어쓰지 않는다
|
||||
- 프로젝트 특화 경로(예: `app/screens/`)를 skill 본문에 하드코딩하지 않는다
|
||||
- skill 생성과 무관한 코드 파일을 수정하지 않는다
|
||||
- 라우팅 대상 파일의 기존 항목을 삭제하거나 재정렬하지 않는다
|
||||
- 하나의 skill 에 여러 독립적인 책임을 묶지 않는다
|
||||
228
agent-ops/skills/common/init-agent-ops/SKILL.md
Normal file
228
agent-ops/skills/common/init-agent-ops/SKILL.md
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
---
|
||||
name: init-agent-ops
|
||||
version: 1.1.0
|
||||
description: 프로젝트 상태를 판별하고 agent-ops 기본 스캐폴드를 생성하기 위한 초기 규칙
|
||||
---
|
||||
|
||||
# init-agent-ops
|
||||
|
||||
## 목적
|
||||
|
||||
프로젝트에 Agent-Ops 구조가 없거나 불완전할 때,
|
||||
현재 프로젝트 상태를 분석하여 다음을 세팅한다.
|
||||
|
||||
1. 에이전트 진입 파일
|
||||
2. agent-ops 기본 폴더 구조
|
||||
3. rules/project/rules.md (프로젝트 특화 규칙, 분석 후 생성)
|
||||
4. 초기 domain rule 초안
|
||||
5. 초기 skill (필요 시)
|
||||
|
||||
"현재 프로젝트에 맞는 최소 스캐폴드" 생성을 우선한다.
|
||||
|
||||
## 언제 호출할지
|
||||
|
||||
- 프로젝트에 agent-ops 구조가 없을 때
|
||||
- agent-ops 구조가 불완전하여 재설정이 필요할 때
|
||||
- 사용자가 "agent-ops 초기화해줘", "에이전트 설정해줘" 요청 시
|
||||
|
||||
## 입력
|
||||
|
||||
- `project-type`: 신규 / 운영중 (선택, 미지정 시 자동 판별)
|
||||
|
||||
## 먼저 확인할 것
|
||||
|
||||
- [ ] 프로젝트 루트에 기존 agent-ops 관련 파일(`CLAUDE.md`, `agent-ops/` 등)이 있는지 확인
|
||||
- [ ] `.agent-ops-source` 파일이 있는지 확인 (공통 관리 레포 여부)
|
||||
- [ ] 기존 진입 파일(`CLAUDE.md`, `GEMINI.md` 등)이 있는지 확인
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
- 기존 구조를 우선한다.
|
||||
- 새 파일 생성은 꼭 필요한 최소 범위로 제한한다.
|
||||
- 도메인은 발명하지 말고 현재 구조에서 발견한다.
|
||||
- 처음에는 핵심 도메인만 생성한다.
|
||||
- 반복되는 작업만 초기 skill로 만든다.
|
||||
- 불확실한 내용은 후보로 제시한다.
|
||||
|
||||
## 상태 판별
|
||||
|
||||
### 신규 프로젝트
|
||||
- 코드/폴더 구조가 단순하다
|
||||
- 도메인 경계가 아직 약하다
|
||||
- Agent-Ops 관련 파일이 없다
|
||||
|
||||
### 운영중 프로젝트
|
||||
- 모듈/폴더/패키지 경계가 보인다
|
||||
- 반복 작업이 드러난다
|
||||
- 핵심 책임 경계가 식별된다
|
||||
|
||||
## 생성 대상
|
||||
|
||||
| 파일 | 방법 |
|
||||
|------|------|
|
||||
| `CLAUDE.md`,`GEMINI.md`,`AGENTS.md`, `.cursorrules`, `.clinerules` 등 진입 파일 | `rules/common/rules.md`를 에이전트별 파일명으로 프로젝트 루트에 복사 후 `rules/common/rules.md` 삭제 |
|
||||
| `agent-ops/.version` | 공통 관리 레포의 VERSION 파일을 그대로 복사 (프레임워크 버전 추적용) |
|
||||
| `agent-ops/rules/common/` | 공통 폴더 전체 복사 (수정 금지) |
|
||||
| `agent-ops/skills/common/` | 공통 폴더 전체 복사 (수정 금지) |
|
||||
| `agent-ops/rules/project/rules.md` | 프로젝트 분석 후 생성 |
|
||||
| `agent-ops/rules/project/domain/<domain>/rules.md` | 도메인 분석 후 생성 |
|
||||
| `agent-ops/rules/private/` | 폴더만 생성 (내용은 개인이 작성) |
|
||||
| `.gitignore`에 `agent-ops/rules/private/` 추가 | git 추적 제외 |
|
||||
|
||||
에이전트별 파일명:
|
||||
|
||||
| 에이전트 | 파일명 |
|
||||
|---------|--------|
|
||||
| Claude | `CLAUDE.md` |
|
||||
| Gemini | `GEMINI.md` |
|
||||
| Kilo Code / OpenCode | `AGENTS.md` |
|
||||
| Cursor | `.cursorrules` |
|
||||
| Cline | `.clinerules` |
|
||||
|
||||
## 에이전트 진입 파일 원칙
|
||||
|
||||
진입 파일은 `rules/common/rules.md` 내용 그대로를 에이전트별 파일명으로 복사한다.
|
||||
별도 내용을 추가하거나 수정하지 않는다.
|
||||
|
||||
## Rule 구조 원칙
|
||||
|
||||
### rules/common/rules.md
|
||||
- 공통 관리 레포에서 제공. 프로젝트에서 직접 수정하지 않는다.
|
||||
- 실제 사용은 진입점 파일들에서 사용된다.
|
||||
|
||||
포함 항목:
|
||||
- 기본 원칙
|
||||
- 공통 스킬 라우팅 (router.md 참조)
|
||||
- project/rules.md 로드 지시
|
||||
|
||||
### rules/project/rules.md
|
||||
init-agent-ops가 프로젝트를 분석하여 생성한다.
|
||||
|
||||
포함 항목:
|
||||
- 응답 언어
|
||||
- 프로젝트 개요 / 주요 구조
|
||||
- 기술 스택
|
||||
- 프로젝트 특화 컨벤션
|
||||
- 도메인 매핑 테이블 (경로 패턴 → domain rules.md)
|
||||
- 프로젝트 스킬 라우팅 (해당 시)
|
||||
|
||||
common/rules.md와 내용이 중복되지 않도록 한다.
|
||||
|
||||
#### 도메인 매핑 테이블 형식
|
||||
|
||||
```markdown
|
||||
## 도메인 매핑
|
||||
|
||||
| 경로 패턴 | 도메인 | rules.md |
|
||||
|----------|--------|----------|
|
||||
| `src/order/**` | order | `agent-ops/rules/project/domain/order/rules.md` |
|
||||
| `src/payment/**` | payment | `agent-ops/rules/project/domain/payment/rules.md` |
|
||||
| `src/common/**` | common | `agent-ops/rules/project/domain/common/rules.md` |
|
||||
```
|
||||
|
||||
#### 프로젝트 스킬 라우팅 형식
|
||||
|
||||
```markdown
|
||||
## 스킬 라우팅
|
||||
|
||||
| 요청 키워드 | SKILL.md |
|
||||
|------------|----------|
|
||||
| 테스트 실행해줘 | `agent-ops/skills/project/run-test/SKILL.md` |
|
||||
```
|
||||
|
||||
### domain rule
|
||||
각 도메인 rules.md에는 아래만 둔다.
|
||||
|
||||
- 목적 / 책임
|
||||
- 포함 경로
|
||||
- 제외 경로
|
||||
- 주요 구성 요소
|
||||
- 유지할 패턴
|
||||
- 다른 도메인과의 경계
|
||||
- 금지 사항
|
||||
|
||||
## DDD 기준
|
||||
|
||||
- **Core Domain**: 핵심 가치와 주요 유즈케이스
|
||||
- **Supporting Domain**: 핵심 도메인을 지원
|
||||
- **Generic / Common**: 여러 도메인이 공통으로 사용
|
||||
|
||||
도메인은 실제 폴더, 모듈, 패키지, 책임 경계와 연결되어야 한다.
|
||||
|
||||
## 실행 (Execution)
|
||||
|
||||
지정한 대상 디렉토리에 agent-ops 스캐폴드를 생성한다.
|
||||
|
||||
```bash
|
||||
./agent-ops/bin/init-agent-ops.sh <target_directory>
|
||||
```
|
||||
|
||||
## 실행 절차
|
||||
|
||||
1. **상태 판별**
|
||||
- 프로젝트 구조, 모듈 경계, agent-ops 파일 유무를 분석하여 신규/운영중을 판별한다
|
||||
|
||||
2. **에이전트 진입 파일 생성**
|
||||
- `rules/common/rules.md`를 에이전트별 파일명으로 프로젝트 루트에 복사한다
|
||||
- 복사 후 `rules/common/rules.md`를 삭제한다
|
||||
|
||||
3. **agent-ops 폴더 구조 복사**
|
||||
- 공통 관리 레포의 `agent-ops/` 공통 폴더(rules/common, skills/common)를 복사한다
|
||||
- `agent-ops/.version` 파일을 복사한다
|
||||
|
||||
4. **rules/project/rules.md 생성**
|
||||
- 프로젝트를 분석하여 응답 언어, 프로젝트 개요, 기술 스택 등을 채운다
|
||||
- common/rules.md와 내용이 중복되지 않도록 한다
|
||||
|
||||
5. **도메인 분석 및 domain rule 생성**
|
||||
- 신규 프로젝트: 핵심 domain placeholder 2~4개만 제안한다. domain rules를 과도하게 채우지 않는다
|
||||
- 운영중 프로젝트: Core/Supporting/Generic 도메인을 식별하고 실제 경로를 반영한 초안을 생성한다
|
||||
|
||||
6. **초기 skill 제안**
|
||||
- 신규 프로젝트: 최소 2개만 제안한다
|
||||
- 운영중 프로젝트: 반복 작업 기반으로 필요한 skill을 제안한다
|
||||
|
||||
7. **결과 보고**
|
||||
- 상태 판별 결과, 생성된 파일 목록, 도메인 제안, skill 제안, 주의사항을 출력한다
|
||||
|
||||
## 출력 형식
|
||||
|
||||
### 상태 판별
|
||||
- Agent-Ops 상태: 없음 / 부분 적용 / 운영중
|
||||
- 프로젝트 상태: 신규 / 운영중
|
||||
- 근거: 짧게 요약
|
||||
|
||||
### 스캐폴드 계획
|
||||
- 생성할 파일
|
||||
- 바로 채울 파일
|
||||
- placeholder로 둘 파일
|
||||
|
||||
### 도메인 제안
|
||||
- Core Domain
|
||||
- Supporting Domain
|
||||
- Generic / Common
|
||||
|
||||
### 초기 skill 제안
|
||||
- skill 이름
|
||||
- 필요한 이유
|
||||
|
||||
### 주의사항
|
||||
- 지금 만들지 말아야 할 것
|
||||
- 아직 확정하면 안 되는 것
|
||||
|
||||
## 실행 결과 검증
|
||||
|
||||
- [ ] 진입 파일이 프로젝트 루트에 존재하고, 내용이 `rules/common/rules.md`와 일치하는가
|
||||
- [ ] `agent-ops/rules/project/rules.md`가 생성되었고, 필수 항목(응답 언어, 프로젝트 개요, 기술 스택)이 포함되어 있는가
|
||||
- [ ] 생성된 domain rules.md가 `domain-rule-template.md` 형식을 따르는가
|
||||
- [ ] `rules/project/rules.md`의 도메인 매핑 테이블에 생성된 도메인이 모두 등록되어 있는가
|
||||
- [ ] `.gitignore`에 `agent-ops/rules/private/` 항목이 추가되어 있는가
|
||||
- 검증 실패 시: 누락된 파일/항목을 사용자에게 알리고 해당 부분만 보완한다
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- 진입 파일에 rules/common/rules.md 외 내용을 추가하지 않는다.
|
||||
- 실제 구조보다 앞선 추상 구조를 강요하지 않는다.
|
||||
- 처음부터 많은 domain / skill을 만들지 않는다.
|
||||
- rules/common/rules.md를 프로젝트에서 직접 수정하지 않는다.
|
||||
- rules/project/rules.md에 rules/common/rules.md와 중복되는 내용을 넣지 않는다.
|
||||
202
agent-ops/skills/common/plan/SKILL.md
Normal file
202
agent-ops/skills/common/plan/SKILL.md
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
---
|
||||
name: plan
|
||||
description: Analyze the current repository and write a detailed PLAN.md for implementation work. Also writes the CODE_REVIEW.md stub that the implementing agent will fill in after coding. Use for any feature, refactor, bug fix, or follow-up fix that should enter the plan-code-review loop. A separate implementing agent, or the same agent in an implementation pass, reads PLAN.md and does the coding. The code-review skill archives both files after review.
|
||||
---
|
||||
|
||||
# Plan
|
||||
|
||||
## Purpose
|
||||
|
||||
Create the planning artifacts for the implementation loop:
|
||||
|
||||
```text
|
||||
plan skill -> PLAN.md + CODE_REVIEW.md stub
|
||||
implementation -> code changes + filled CODE_REVIEW.md
|
||||
code-review skill -> verdict + archive, or new follow-up PLAN.md
|
||||
```
|
||||
|
||||
## Workflow Contract
|
||||
|
||||
This skill intentionally uses `agent-task/{task_name}/PLAN.md` and `agent-task/{task_name}/CODE_REVIEW.md` as the state protocol between planning, implementation, and review. Do not change these paths or filenames unless the paired plan and code-review skills are updated together. This convention is not project-specific; it is the workflow contract for this skill loop.
|
||||
|
||||
Directory states:
|
||||
|
||||
| State | Meaning |
|
||||
|-------|---------|
|
||||
| `PLAN.md` only | Invalid; plan skill always writes both files |
|
||||
| `PLAN.md` + `CODE_REVIEW.md` stub | Implementation is pending/in progress |
|
||||
| `PLAN.md` + filled `CODE_REVIEW.md` | Ready for code-review skill |
|
||||
| `complete.log` + `*.log` files | Task complete (PASS) |
|
||||
| Only `*.log` files (no `complete.log`) | Task terminated mid-loop or abandoned |
|
||||
|
||||
## Step 1 - Determine Task
|
||||
|
||||
If the user names the task explicitly, use that task name.
|
||||
|
||||
Otherwise, glob `agent-task/*/PLAN.md`:
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Exactly one | Continue that task |
|
||||
| None | Create a new task only for a feature/refactor/fix/follow-up that belongs in this workflow |
|
||||
| Multiple | List paths and ask which task to use |
|
||||
|
||||
`PLAN.md` is the loop entry point. A missing `PLAN.md` normally means only that no plan has been started for a new task; do not create task files for casual analysis, status, or review requests unless the user explicitly asks for a plan.
|
||||
|
||||
Use short snake_case task names, e.g. `api_refactor`.
|
||||
|
||||
## Step 2 - Analyze Before Writing
|
||||
|
||||
Complete these before creating files:
|
||||
|
||||
- Read every source file the change will touch, whole file.
|
||||
- Read every test file that exercises changed behavior.
|
||||
- For each behavior change, note whether existing tests cover it.
|
||||
- Grep renamed/removed symbols for all call sites and import chains.
|
||||
- Check package manifests/dependency files before adding dependencies.
|
||||
- Anticipate compile issues: missing overrides, type mismatches, broken imports.
|
||||
- Confirm final verification commands work in this repository layout.
|
||||
|
||||
## Step 3 - Archive Existing Active Files
|
||||
|
||||
Before writing new active files for the chosen task:
|
||||
|
||||
- Count existing `agent-task/{task_name}/plan_*.log`; call it `N`. If `PLAN.md` exists, rename it to `plan_N.log`.
|
||||
- Count existing `agent-task/{task_name}/code_review_*.log`; call it `M`. If `CODE_REVIEW.md` exists, rename it to `code_review_M.log`.
|
||||
|
||||
The new plan number is the count of `plan_*.log` after archiving.
|
||||
|
||||
## Step 4 - Write PLAN.md
|
||||
|
||||
Header line must be exactly:
|
||||
|
||||
```markdown
|
||||
<!-- task={task_name} plan={N} tag={TAG} -->
|
||||
```
|
||||
|
||||
Required sections:
|
||||
|
||||
- Title.
|
||||
- `이 파일을 읽는 구현 에이전트에게`: tell the implementer to complete checklists, run intermediate/final verification, and fill every `CODE_REVIEW.md` section with actual implementation notes and command output.
|
||||
- `배경`: 2-4 sentences explaining why the work is needed.
|
||||
- One item per change: `### [TAG-1] Title`, `TAG-2`, etc.
|
||||
- `수정 파일 요약`: table mapping files to item ids.
|
||||
- `최종 검증`: runnable commands and expected outcome.
|
||||
|
||||
Each plan item must include:
|
||||
|
||||
- `문제`: concrete problem with file:line references.
|
||||
- `해결 방법`: exact approach and before/after code block for non-trivial changes.
|
||||
- `수정 파일 및 체크리스트`: exhaustive file-level checklist.
|
||||
- `테스트 작성`: explicit write/skip decision. If writing tests, include path, test name, assertion goal, and fixtures. If skipping, justify.
|
||||
- `중간 검증`: runnable commands and expected result.
|
||||
|
||||
Include `의존 관계 및 구현 순서` only when order matters.
|
||||
|
||||
Quality rules:
|
||||
|
||||
- Exact line numbers in every Before snippet.
|
||||
- Use the language's required override annotation/keyword wherever an abstract method is implemented.
|
||||
- Include full import statements for new packages.
|
||||
- List all call sites for renamed/removed symbols.
|
||||
- Never write "add tests as needed"; decide up front.
|
||||
- Do not cite files you did not read.
|
||||
|
||||
Test policy:
|
||||
|
||||
| Change | Test requirement |
|
||||
|--------|------------------|
|
||||
| Bug fix | Regression test required |
|
||||
| New public API | Normal + boundary tests required |
|
||||
| API rename | Existing test call-site updates usually enough |
|
||||
| Internal refactor | Existing tests may be enough |
|
||||
| Concurrency logic | Race/ordering test recommended |
|
||||
|
||||
## Step 5 - Write CODE_REVIEW.md Stub
|
||||
|
||||
Use the template below exactly. Fill `{…}` placeholders from the plan; everything else is fixed and must not be changed by the implementing agent.
|
||||
|
||||
```markdown
|
||||
<!-- task={task_name} plan={N} tag={TAG} -->
|
||||
|
||||
# Code Review Reference - {TAG}
|
||||
|
||||
## 개요
|
||||
|
||||
date={YYYY-MM-DD}
|
||||
task={task_name}, plan={N}, tag={TAG}
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
|
||||
|
||||
1. `CODE_REVIEW.md` → `code_review_N.log` (N = 기존 code_review_*.log 수)
|
||||
2. `PLAN.md` → `plan_M.log` (M = 기존 plan_*.log 수)
|
||||
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [{TAG}-1] {item description} | [ ] |
|
||||
| [{TAG}-2] {item description} | [ ] |
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
{pre-filled from plan — one bullet per review focus area}
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
### {TAG}-1 중간 검증
|
||||
```
|
||||
$ {verification command from plan}
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```
|
||||
$ {final verification command from plan}
|
||||
(output)
|
||||
```
|
||||
```
|
||||
|
||||
Sections and their ownership:
|
||||
|
||||
| 섹션 | 소유자 | 설명 |
|
||||
|------|--------|------|
|
||||
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음 |
|
||||
| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` → `[x]` 체크만 구현 에이전트가 수행 |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 |
|
||||
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
|
||||
| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움 |
|
||||
| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 |
|
||||
|
||||
## Naming
|
||||
|
||||
| Tag | Use for |
|
||||
|-----|---------|
|
||||
| `API` | Public API changes |
|
||||
| `REFACTOR` | Internal refactoring |
|
||||
| `TEST` | Test additions/fixes |
|
||||
| `REVIEW_<TAG>` | Follow-up fixes after review |
|
||||
|
||||
## Final Checklist
|
||||
|
||||
- `PLAN.md` and `CODE_REVIEW.md` both exist under `agent-task/{task_name}/`.
|
||||
- Both first lines match `<!-- task={task_name} plan={N} tag={TAG} -->`.
|
||||
- Previous active files, if any, were archived with correct numeric suffixes.
|
||||
- Every plan item has problem, solution, checklist, test decision, and intermediate verification.
|
||||
- `CODE_REVIEW.md` completion table lists every plan item.
|
||||
4
agent-ops/skills/common/plan/agents/openai.yaml
Normal file
4
agent-ops/skills/common/plan/agents/openai.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
interface:
|
||||
display_name: "Plan"
|
||||
short_description: "Write implementation plans"
|
||||
default_prompt: "Use $plan to create a task PLAN.md and CODE_REVIEW.md stub for this repository change."
|
||||
13
agent-ops/skills/common/router.md
Normal file
13
agent-ops/skills/common/router.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# 공통 스킬 라우터
|
||||
|
||||
| 요청 키워드 | SKILL.md |
|
||||
|------------|----------|
|
||||
| agent-ops 세팅해줘, scaffold 만들어줘, 초기화해줘 | `agent-ops/skills/common/init-agent-ops/SKILL.md` |
|
||||
| domain rule 만들어줘, rules.md 생성, 새 도메인 규칙 | `agent-ops/skills/common/create-domain-rule/SKILL.md` |
|
||||
| skill 만들어줘, SKILL.md 생성, 새 스킬 추가 | `agent-ops/skills/common/create-skill/SKILL.md` |
|
||||
| 계획 세워줘, 구현 계획, PLAN.md, plan | `agent-ops/skills/common/plan/SKILL.md` |
|
||||
| 코드 리뷰해줘, code review, CODE_REVIEW.md, 리뷰 루프 | `agent-ops/skills/common/code-review/SKILL.md` |
|
||||
| 커밋해줘, 푸시해줘, commit, push, 반영해줘 | `agent-ops/skills/common/commit-push/SKILL.md` |
|
||||
| agent-ops 싱크해, agent-ops 동기화해, agentic-framework에 올려줘, agent-ops를 [프로젝트]로 싱크해 | `agent-ops/skills/common/sync-push/SKILL.md` |
|
||||
| agent-ops pull해, agent-ops 가져와, agentic-framework에서 가져와, agent-ops 내려받아 | `agent-ops/skills/common/sync-pull/SKILL.md` |
|
||||
| 도메인 업데이트, domain rule 갱신, 도메인 검토, domain 스캔 | `agent-ops/skills/common/update-domain-rule/SKILL.md` |
|
||||
36
agent-ops/skills/common/sync-pull/SKILL.md
Normal file
36
agent-ops/skills/common/sync-pull/SKILL.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
name: sync-pull
|
||||
description: agentic-framework에서 현재 프로젝트로 agent-ops를 내려받는다. "agent-ops pull해", "agentic-framework에서 가져와" 요청 시 사용한다.
|
||||
---
|
||||
|
||||
# sync-pull
|
||||
|
||||
## 목적
|
||||
|
||||
`agent-ops/bin/sync.sh --pull`을 호출해 agentic-framework → 현재 프로젝트 방향으로 agent-ops를 내려받는다.
|
||||
|
||||
## 언제 호출할지
|
||||
|
||||
- "agent-ops pull해", "agent-ops 가져와" 요청 시
|
||||
- "agentic-framework에서 가져와" 요청 시
|
||||
- "agent-ops 내려받아" 요청 시
|
||||
|
||||
## 실행 절차
|
||||
|
||||
1. 현재 프로젝트의 **상위 폴더(`../`)** 에 `agentic-framework` 폴더가 있는지 확인한다 (현재 폴더 내부가 아님)
|
||||
2. **있으면**: `agent-ops/bin/sync.sh --pull agentic-framework` 실행
|
||||
3. **없으면**: 사용자에게 agentic-framework 경로 입력을 안내하고, 입력받은 경로로 실행
|
||||
|
||||
```bash
|
||||
agent-ops/bin/sync.sh --pull <target>
|
||||
```
|
||||
|
||||
## 실행 결과 검증
|
||||
|
||||
- [ ] sync.sh 가 오류 없이 완료됐는가
|
||||
- [ ] 현재 프로젝트 버전이 framework 버전과 일치하는가
|
||||
- [ ] 진입 파일(CLAUDE.md 등)이 갱신됐는가
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- sync.sh 를 거치지 않고 직접 파일을 복사하지 않는다
|
||||
43
agent-ops/skills/common/sync-push/SKILL.md
Normal file
43
agent-ops/skills/common/sync-push/SKILL.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
name: sync-push
|
||||
description: 현재 프로젝트의 agent-ops를 agentic-framework로 올린다. "agent-ops 싱크해", "agent-ops 동기화해", "agentic-framework에 올려줘" 요청 시 사용한다.
|
||||
---
|
||||
|
||||
# sync-push
|
||||
|
||||
## 목적
|
||||
|
||||
`agent-ops/bin/sync.sh`를 호출해 현재 프로젝트 → agentic-framework 방향으로 agent-ops를 올린다.
|
||||
|
||||
## 언제 호출할지
|
||||
|
||||
- "agent-ops 싱크해", "agent-ops 동기화해" 요청 시
|
||||
- "agentic-framework에 올려줘" 요청 시
|
||||
- "agent-ops를 [프로젝트]로 싱크해" 요청 시
|
||||
|
||||
## 실행 절차
|
||||
|
||||
### 현 프로젝트가 일반 프로젝트인 경우 (`.agent-ops-source` 없음)
|
||||
|
||||
1. 현재 프로젝트의 **상위 폴더(`../`)** 에 `agentic-framework` 폴더가 있는지 확인한다 (현재 폴더 내부가 아님)
|
||||
2. **있으면**: `agent-ops/bin/sync.sh agentic-framework` 실행
|
||||
3. **없으면**: 사용자에게 agentic-framework 경로 입력을 안내하고, 입력받은 경로로 실행
|
||||
|
||||
### 현 프로젝트가 agentic-framework인 경우 (`.agent-ops-source` 있음)
|
||||
|
||||
1. 사용자 요청에서 target 프로젝트명 또는 경로를 추출한다
|
||||
2. **명시된 경우**: `agent-ops/bin/sync.sh <target>` 실행
|
||||
3. **명시되지 않은 경우**: 사용자에게 대상 프로젝트 입력을 요청한다
|
||||
|
||||
```bash
|
||||
agent-ops/bin/sync.sh <target>
|
||||
```
|
||||
|
||||
## 실행 결과 검증
|
||||
|
||||
- [ ] sync.sh 가 오류 없이 완료됐는가
|
||||
- [ ] 버전 충돌 경고가 없었는가 — 있었다면 사용자에게 수동 머지 필요함을 알린다
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- sync.sh 를 거치지 않고 직접 파일을 복사하지 않는다
|
||||
100
agent-ops/skills/common/update-domain-rule/SKILL.md
Normal file
100
agent-ops/skills/common/update-domain-rule/SKILL.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
---
|
||||
name: update-domain-rule
|
||||
version: 1.0.0
|
||||
description: 기존 도메인 rules.md를 코드 현황에 맞게 갱신. 전체 스캔(full) 또는 지정 도메인(targeted) 두 모드 지원
|
||||
---
|
||||
|
||||
# update-domain-rule
|
||||
|
||||
## 목적
|
||||
|
||||
프로젝트 코드가 변경되면서 기존 domain rule이 실제 구조와 어긋날 수 있다.
|
||||
이 스킬은 실제 파일 구조를 탐색하여 기존 `rules.md`와 비교하고, 누락·오류·구식 항목을 수정한다.
|
||||
|
||||
## 언제 호출할지
|
||||
|
||||
- 대규모 리팩터링 또는 외부 패키지 내재화 후 domain rule 동기화가 필요할 때
|
||||
- 특정 도메인 파일 구조가 바뀌어 기존 rule이 맞지 않을 때
|
||||
- 사용자가 "도메인 업데이트", "domain rule 갱신", "domain 검토" 등을 요청할 때
|
||||
- agent-ops 초기 scaffold 이후 코드가 많이 달라진 경우
|
||||
|
||||
## 입력
|
||||
|
||||
- `mode`: `full`(전체 도메인 스캔) | `targeted`(지정 도메인만) (필수)
|
||||
- `domain-name`: 업데이트할 도메인 이름 — `targeted` 모드에서만 필수
|
||||
|
||||
## 먼저 확인할 것
|
||||
|
||||
- [ ] `agent-ops/rules/project/domain/` 하위 기존 도메인 목록 확인
|
||||
- [ ] `agent-ops/rules/common/_templates/domain-rule-template.md` 읽어 최신 템플릿 형식 파악
|
||||
- [ ] `targeted` 모드이면 `agent-ops/rules/project/domain/<domain-name>/rules.md` 존재 여부 확인
|
||||
- 존재하지 않으면 `create-domain-rule` 스킬을 사용하도록 안내하고 중단
|
||||
|
||||
## 실행 절차
|
||||
|
||||
1. **대상 목록 결정**
|
||||
- `full`: `agent-ops/rules/project/domain/` 하위 모든 도메인 디렉터리를 대상으로 한다
|
||||
- `targeted`: 지정된 `domain-name` 하나만 대상으로 한다
|
||||
|
||||
2. **도메인별 코드 탐색**
|
||||
- 기존 `rules.md`의 **포함 경로** 목록을 기준으로 실제 파일 구조 탐색
|
||||
- 포함 경로에 없지만 도메인 이름과 연관된 경로도 함께 탐색
|
||||
- 탐색 시 실제로 존재하는 경로만 수집한다
|
||||
|
||||
3. **비교 및 변경 항목 식별**
|
||||
다음 항목 각각을 현재 `rules.md`와 비교한다:
|
||||
- **포함 경로**: 실제로 존재하지 않는 경로 제거, 새로 생긴 경로 추가
|
||||
- **주요 구성 요소**: 파일/클래스 삭제·이름 변경·신규 추가 반영
|
||||
- **유지할 패턴**: 코드에서 더 이상 사용되지 않는 패턴 제거, 새 패턴 추가
|
||||
- **다른 도메인과의 경계**: 도메인 간 import 관계가 바뀐 경우 반영
|
||||
- **목적/책임**: 도메인 책임이 실질적으로 변경된 경우에만 수정
|
||||
|
||||
4. **rules.md 업데이트**
|
||||
- 변경이 필요한 항목만 수정한다 — 변경 불필요한 섹션은 그대로 둔다
|
||||
- 확인된 사실만 기재한다; 불확실하면 `<!-- TODO: 확인 필요 -->` 주석 처리
|
||||
- `domain-rule-template.md`의 섹션 구조를 유지한다
|
||||
|
||||
5. **도메인 매핑 테이블 검토** (`full` 모드 시)
|
||||
- `agent-ops/rules/project/rules.md`의 도메인 매핑 테이블과 실제 포함 경로 비교
|
||||
- 누락된 경로 패턴은 추가, 존재하지 않는 경로 패턴은 제거
|
||||
- 기존 항목 순서는 변경하지 않는다
|
||||
|
||||
6. **결과 보고**
|
||||
- 도메인별로 수정한 항목 목록
|
||||
- `TODO`로 남긴 항목 (해당 시)
|
||||
- 도메인 매핑 테이블 변경 내용 (full 모드 시)
|
||||
|
||||
## 실행 결과 검증
|
||||
|
||||
- [ ] 수정된 `rules.md`의 포함 경로가 모두 실제 프로젝트에 존재하는가
|
||||
- [ ] 섹션 구조가 `domain-rule-template.md` 형식을 유지하는가
|
||||
- [ ] 변경하지 않아도 되는 섹션이 의도치 않게 바뀌지 않았는가
|
||||
- [ ] `full` 모드에서 도메인 매핑 테이블이 실제 포함 경로와 일치하는가
|
||||
- 검증 실패 시: 실제 존재하지 않는 경로나 누락된 섹션을 사용자에게 알리고 해당 항목만 보완한다
|
||||
|
||||
## 출력 형식
|
||||
|
||||
```
|
||||
## 업데이트 완료
|
||||
|
||||
### <domain-name>
|
||||
- 변경: <수정된 항목 요약>
|
||||
- 추가: <새로 추가된 경로/구성 요소>
|
||||
- 제거: <삭제된 경로/구성 요소>
|
||||
- 유지: 변경 없음 (해당 섹션)
|
||||
|
||||
### 도메인 매핑 테이블 (full 모드 시)
|
||||
- 추가: <새 경로 패턴>
|
||||
- 제거: <삭제된 경로 패턴>
|
||||
|
||||
## TODO 항목 (확인 필요)
|
||||
- <불확실하여 직접 확인이 필요한 항목> (해당 시)
|
||||
```
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- 실제 존재하지 않는 경로를 포함 경로에 기재하지 않는다
|
||||
- 추측으로 패턴·금지 사항을 추가하지 않는다 — 코드에서 확인된 내용만 기재한다
|
||||
- 신규 도메인 생성이 필요한 경우 직접 생성하지 않고 `create-domain-rule` 스킬 사용을 안내한다
|
||||
- `rules/project/rules.md`의 기존 항목을 삭제하거나 재정렬하지 않는다
|
||||
- 코드 파일을 수정하지 않는다 — 이 스킬은 rule 파일 갱신만 담당한다
|
||||
75
agent-task/node_testclient/CODE_REVIEW.md
Normal file
75
agent-task/node_testclient/CODE_REVIEW.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<!-- task=node_testclient plan=0 tag=TEST -->
|
||||
|
||||
# Code Review Reference - TEST
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-05-02
|
||||
task=node_testclient, plan=0, tag=TEST
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
|
||||
|
||||
1. `CODE_REVIEW.md` → `code_review_0.log`
|
||||
2. `PLAN.md` → `plan_0.log`
|
||||
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [TEST-1] iop-node-testclient 커맨드 생성 | [ ] |
|
||||
| [TEST-2] Makefile에 testclient 빌드 타겟 추가 | [ ] |
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `sendMsg` / `recvEnvelope` 의 프레임 포맷이 서버 `transport/frame.go` 와 동일한 `4-byte big-endian length | JSON(Envelope)` 인지 확인
|
||||
- `internal` 경로 패키지를 import하지 않고 `iop/packages/protocol` 만 사용하는지 확인
|
||||
- `-caps` 플래그 없이 실행해도 RunRequest → RunEvent 흐름이 정상 동작하는지 확인
|
||||
- 60s 타임아웃 이전에 complete/error 이벤트 수신 시 정상 종료하는지 확인
|
||||
- Makefile `build` 타겟에 `iop-node-testclient` 라인이 추가되었는지 확인
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
### TEST-1 중간 검증
|
||||
```
|
||||
$ go build -o bin/iop-node-testclient ./apps/node/cmd/iop-node-testclient
|
||||
(output)
|
||||
```
|
||||
|
||||
### TEST-2 중간 검증
|
||||
```
|
||||
$ make build
|
||||
$ ls -la bin/
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```
|
||||
# 터미널 A
|
||||
$ ./bin/iop-node serve --config configs/node.yaml
|
||||
(output)
|
||||
|
||||
# 터미널 B
|
||||
$ ./bin/iop-node-testclient
|
||||
$ ./bin/iop-node-testclient -caps
|
||||
$ ./bin/iop-node-testclient -prompt "안녕하세요 IOP!"
|
||||
(output)
|
||||
|
||||
$ go build ./...
|
||||
(output)
|
||||
```
|
||||
345
agent-task/node_testclient/PLAN.md
Normal file
345
agent-task/node_testclient/PLAN.md
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
<!-- 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 ./...
|
||||
```
|
||||
82
agent-task/node_unit_tests/CODE_REVIEW.md
Normal file
82
agent-task/node_unit_tests/CODE_REVIEW.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<!-- task=node_unit_tests plan=0 tag=TEST -->
|
||||
|
||||
# Code Review Reference - TEST
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-05-02
|
||||
task=node_unit_tests, plan=0, tag=TEST
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
|
||||
|
||||
1. `CODE_REVIEW.md` → `code_review_0.log`
|
||||
2. `PLAN.md` → `plan_0.log`
|
||||
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [TEST-1] transport/frame 유닛 테스트 | [ ] |
|
||||
| [TEST-2] transport/codec 유닛 테스트 | [ ] |
|
||||
| [TEST-3] mock adapter 유닛 테스트 | [ ] |
|
||||
| [TEST-4] router 유닛 테스트 | [ ] |
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- frame_test.go: `readFrame` / `writeFrame` 모두 `package transport` 화이트박스로 접근하는지 확인
|
||||
- codec_test.go: 잘못된 JSON 입력 시 nil 이 아닌 error 반환 여부
|
||||
- mock_test.go: 이벤트 순서가 start → delta+ → complete 이고 RunID가 모든 이벤트에 일관되게 전파되는지 확인
|
||||
- mock_test.go: cancel 테스트가 `context.Canceled` / `context.DeadlineExceeded` 외 에러는 실패로 처리하는지 확인
|
||||
- router_test.go: 빈 registry 케이스와 미등록 어댑터 케이스 모두 error 반환하는지 확인
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
### TEST-1 중간 검증
|
||||
```
|
||||
$ go test ./apps/node/internal/transport/... -run TestWriteReadFrame -v
|
||||
$ go test ./apps/node/internal/transport/... -run TestReadFrame -v
|
||||
(output)
|
||||
```
|
||||
|
||||
### TEST-2 중간 검증
|
||||
```
|
||||
$ go test ./apps/node/internal/transport/... -run TestEncodeDecodeEnvelope -v
|
||||
$ go test ./apps/node/internal/transport/... -run TestDecodeEnvelope -v
|
||||
$ go test ./apps/node/internal/transport/... -run TestEncodeDecodePayload -v
|
||||
(output)
|
||||
```
|
||||
|
||||
### TEST-3 중간 검증
|
||||
```
|
||||
$ go test ./apps/node/internal/adapters/mock/... -v
|
||||
(output)
|
||||
```
|
||||
|
||||
### TEST-4 중간 검증
|
||||
```
|
||||
$ go test ./apps/node/internal/router/... -v
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```
|
||||
$ go test ./apps/node/internal/transport/... ./apps/node/internal/adapters/mock/... ./apps/node/internal/router/... -v -count=1
|
||||
$ go build ./...
|
||||
(output)
|
||||
```
|
||||
481
agent-task/node_unit_tests/PLAN.md
Normal file
481
agent-task/node_unit_tests/PLAN.md
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
<!-- task=node_unit_tests plan=0 tag=TEST -->
|
||||
|
||||
# Node 유닛 테스트 추가
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
각 체크리스트 항목을 완료한 뒤 `[x]`로 표시하세요.
|
||||
중간 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md` 검증 결과 섹션에 붙여 넣으세요.
|
||||
계획과 다르게 구현한 부분은 반드시 `계획 대비 변경 사항`에 기록하세요.
|
||||
|
||||
## 배경
|
||||
|
||||
IOP 노드 스캐폴드가 완성되었으나 테스트 파일이 없다.
|
||||
transport 레이어(frame/codec)와 핵심 컴포넌트(mock adapter, router)는
|
||||
외부 의존성 없이 순수하게 테스트 가능한 구조이므로 유닛 테스트를 추가해
|
||||
회귀 방지 기반을 만든다.
|
||||
|
||||
---
|
||||
|
||||
### [TEST-1] transport/frame 유닛 테스트
|
||||
|
||||
**문제**
|
||||
|
||||
[apps/node/internal/transport/frame.go:16-46](apps/node/internal/transport/frame.go)의
|
||||
`readFrame` / `writeFrame` 함수가 4-byte length-prefix 프레이밍을 올바르게 수행하는지
|
||||
검증하는 테스트가 없다. maxFrameSize 초과 케이스도 미검증.
|
||||
|
||||
**해결 방법**
|
||||
|
||||
`package transport` (화이트박스) 테스트 파일을 생성한다.
|
||||
unexported 함수를 같은 패키지에서 직접 호출한다.
|
||||
|
||||
```go
|
||||
// apps/node/internal/transport/frame_test.go (신규)
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteReadFrame_roundtrip(t *testing.T) {
|
||||
payload := []byte(`{"type":"heartbeat","timestamp":1234}`)
|
||||
var buf bytes.Buffer
|
||||
if err := writeFrame(&buf, payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readFrame(&buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Errorf("round-trip mismatch: got %q, want %q", got, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadFrame_empty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeFrame(&buf, []byte{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readFrame(&buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty payload, got %d bytes", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrame_tooLarge(t *testing.T) {
|
||||
// 헤더에 maxFrameSize+1 을 직접 기록
|
||||
var buf bytes.Buffer
|
||||
hdr := make([]byte, 4)
|
||||
// encoding/binary 없이 직접 big-endian 기록
|
||||
sz := uint32(maxFrameSize + 1)
|
||||
hdr[0] = byte(sz >> 24)
|
||||
hdr[1] = byte(sz >> 16)
|
||||
hdr[2] = byte(sz >> 8)
|
||||
hdr[3] = byte(sz)
|
||||
buf.Write(hdr)
|
||||
_, err := readFrame(&buf)
|
||||
if err == nil {
|
||||
t.Error("expected error for oversized frame, got nil")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**수정 파일 및 체크리스트**
|
||||
|
||||
- [ ] `apps/node/internal/transport/frame_test.go` 신규 생성
|
||||
- [ ] `TestWriteReadFrame_roundtrip` 작성
|
||||
- [ ] `TestWriteReadFrame_empty` 작성
|
||||
- [ ] `TestReadFrame_tooLarge` 작성
|
||||
|
||||
**테스트 작성**
|
||||
|
||||
이 항목 자체가 테스트 작성 작업이므로 별도 추가 테스트 없음.
|
||||
|
||||
**중간 검증**
|
||||
|
||||
```bash
|
||||
go test ./apps/node/internal/transport/... -run TestWriteReadFrame -v
|
||||
go test ./apps/node/internal/transport/... -run TestReadFrame -v
|
||||
```
|
||||
|
||||
기대 결과: 3개 테스트 PASS
|
||||
|
||||
---
|
||||
|
||||
### [TEST-2] transport/codec 유닛 테스트
|
||||
|
||||
**문제**
|
||||
|
||||
[apps/node/internal/transport/codec.go:10-29](apps/node/internal/transport/codec.go)의
|
||||
`encodeEnvelope` / `decodeEnvelope` / `encodePayload` / `decodePayload` 함수에 테스트 없음.
|
||||
JSON 인코딩 라운드트립과 잘못된 JSON 입력 시 에러 반환 여부가 미검증.
|
||||
|
||||
**해결 방법**
|
||||
|
||||
```go
|
||||
// apps/node/internal/transport/codec_test.go (신규)
|
||||
package transport
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"iop/packages/protocol"
|
||||
)
|
||||
|
||||
func TestEncodeDecodeEnvelope_roundtrip(t *testing.T) {
|
||||
orig := &protocol.Envelope{
|
||||
ProtocolVersion: protocol.ProtocolVersion,
|
||||
RequestID: "req-001",
|
||||
RunID: "run-abc",
|
||||
Type: protocol.TypeHeartbeat,
|
||||
Payload: []byte(`{"timestamp":999}`),
|
||||
}
|
||||
data, err := encodeEnvelope(orig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := decodeEnvelope(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.RequestID != orig.RequestID {
|
||||
t.Errorf("RequestID: got %q, want %q", got.RequestID, orig.RequestID)
|
||||
}
|
||||
if got.RunID != orig.RunID {
|
||||
t.Errorf("RunID: got %q, want %q", got.RunID, orig.RunID)
|
||||
}
|
||||
if got.Type != orig.Type {
|
||||
t.Errorf("Type: got %q, want %q", got.Type, orig.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEnvelope_invalidJSON(t *testing.T) {
|
||||
_, err := decodeEnvelope([]byte(`not-json`))
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodePayload_roundtrip(t *testing.T) {
|
||||
type inner struct {
|
||||
Foo string `json:"foo"`
|
||||
Bar int `json:"bar"`
|
||||
}
|
||||
orig := inner{Foo: "hello", Bar: 42}
|
||||
data, err := encodePayload(orig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got inner
|
||||
if err := decodePayload(data, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Foo != orig.Foo || got.Bar != orig.Bar {
|
||||
t.Errorf("payload mismatch: got %+v, want %+v", got, orig)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**수정 파일 및 체크리스트**
|
||||
|
||||
- [ ] `apps/node/internal/transport/codec_test.go` 신규 생성
|
||||
- [ ] `TestEncodeDecodeEnvelope_roundtrip` 작성
|
||||
- [ ] `TestDecodeEnvelope_invalidJSON` 작성
|
||||
- [ ] `TestEncodeDecodePayload_roundtrip` 작성
|
||||
|
||||
**테스트 작성**
|
||||
|
||||
이 항목 자체가 테스트 작성 작업.
|
||||
|
||||
**중간 검증**
|
||||
|
||||
```bash
|
||||
go test ./apps/node/internal/transport/... -run TestEncodeDecodeEnvelope -v
|
||||
go test ./apps/node/internal/transport/... -run TestDecodeEnvelope -v
|
||||
go test ./apps/node/internal/transport/... -run TestEncodeDecodePayload -v
|
||||
```
|
||||
|
||||
기대 결과: 3개 테스트 PASS
|
||||
|
||||
---
|
||||
|
||||
### [TEST-3] mock adapter 유닛 테스트
|
||||
|
||||
**문제**
|
||||
|
||||
[apps/node/internal/adapters/mock/mock.go:38-72](apps/node/internal/adapters/mock/mock.go)의
|
||||
`Execute` 함수가 start → delta+ → complete 순서로 이벤트를 방출하는지,
|
||||
context cancel 시 조기 종료하는지 미검증.
|
||||
|
||||
**해결 방법**
|
||||
|
||||
`collectSink` 헬퍼 타입으로 이벤트를 수집한 뒤 순서와 타입을 검증한다.
|
||||
|
||||
```go
|
||||
// apps/node/internal/adapters/mock/mock_test.go (신규)
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"iop/apps/node/internal/runtime"
|
||||
)
|
||||
|
||||
type collectSink struct {
|
||||
events []runtime.RuntimeEvent
|
||||
}
|
||||
|
||||
func (c *collectSink) Emit(_ context.Context, e runtime.RuntimeEvent) error {
|
||||
c.events = append(c.events, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMockExecute_sequence(t *testing.T) {
|
||||
m := New(zap.NewNop())
|
||||
sink := &collectSink{}
|
||||
spec := runtime.ExecutionSpec{
|
||||
RunID: "test-run-001",
|
||||
Adapter: Name,
|
||||
Input: map[string]any{"prompt": "hello world"},
|
||||
}
|
||||
|
||||
if err := m.Execute(context.Background(), spec, sink); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if len(sink.events) < 2 {
|
||||
t.Fatalf("expected at least 2 events, got %d", len(sink.events))
|
||||
}
|
||||
|
||||
// 첫 이벤트는 반드시 start
|
||||
if sink.events[0].Type != runtime.EventTypeStart {
|
||||
t.Errorf("first event: got %q, want %q", sink.events[0].Type, runtime.EventTypeStart)
|
||||
}
|
||||
// 마지막 이벤트는 반드시 complete
|
||||
last := sink.events[len(sink.events)-1]
|
||||
if last.Type != runtime.EventTypeComplete {
|
||||
t.Errorf("last event: got %q, want %q", last.Type, runtime.EventTypeComplete)
|
||||
}
|
||||
// complete 이벤트에 Usage 포함 여부
|
||||
if last.Usage == nil {
|
||||
t.Error("complete event: expected Usage, got nil")
|
||||
}
|
||||
// RunID 일관성
|
||||
for i, e := range sink.events {
|
||||
if e.RunID != spec.RunID {
|
||||
t.Errorf("event[%d].RunID: got %q, want %q", i, e.RunID, spec.RunID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockExecute_cancel(t *testing.T) {
|
||||
m := New(zap.NewNop())
|
||||
sink := &collectSink{}
|
||||
spec := runtime.ExecutionSpec{
|
||||
RunID: "test-cancel-001",
|
||||
Adapter: Name,
|
||||
Input: map[string]any{"prompt": "long long long long long long long long prompt"},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := m.Execute(ctx, spec, sink)
|
||||
// context cancel or deadline exceeded 는 정상 종료 케이스
|
||||
if err != nil && err != context.Canceled && err != context.DeadlineExceeded {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockCapabilities(t *testing.T) {
|
||||
m := New(zap.NewNop())
|
||||
caps, err := m.Capabilities(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caps.AdapterName != Name {
|
||||
t.Errorf("AdapterName: got %q, want %q", caps.AdapterName, Name)
|
||||
}
|
||||
if len(caps.Models) == 0 {
|
||||
t.Error("expected at least one model")
|
||||
}
|
||||
if caps.MaxConcurrency <= 0 {
|
||||
t.Error("expected positive MaxConcurrency")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**수정 파일 및 체크리스트**
|
||||
|
||||
- [ ] `apps/node/internal/adapters/mock/mock_test.go` 신규 생성
|
||||
- [ ] `collectSink` 헬퍼 타입 작성
|
||||
- [ ] `TestMockExecute_sequence` 작성
|
||||
- [ ] `TestMockExecute_cancel` 작성
|
||||
- [ ] `TestMockCapabilities` 작성
|
||||
|
||||
**테스트 작성**
|
||||
|
||||
이 항목 자체가 테스트 작성 작업.
|
||||
|
||||
**중간 검증**
|
||||
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/mock/... -v
|
||||
```
|
||||
|
||||
기대 결과: 3개 테스트 PASS (cancel 테스트는 50ms 내 종료)
|
||||
|
||||
---
|
||||
|
||||
### [TEST-4] router 유닛 테스트
|
||||
|
||||
**문제**
|
||||
|
||||
[apps/node/internal/router/router.go:24-47](apps/node/internal/router/router.go)의
|
||||
`Resolve` 함수가 adapter 이름 미지정 시 default 선택, 명시 시 해당 어댑터 선택,
|
||||
미등록 어댑터 요청 시 에러 반환하는지 미검증.
|
||||
|
||||
**해결 방법**
|
||||
|
||||
```go
|
||||
// apps/node/internal/router/router_test.go (신규)
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"iop/apps/node/internal/adapters"
|
||||
"iop/apps/node/internal/adapters/mock"
|
||||
"iop/apps/node/internal/runtime"
|
||||
)
|
||||
|
||||
func newTestRegistry() *adapters.Registry {
|
||||
reg := adapters.NewRegistry()
|
||||
reg.Register(mock.New(zap.NewNop()))
|
||||
return reg
|
||||
}
|
||||
|
||||
func TestRouter_resolveDefault(t *testing.T) {
|
||||
r := New(newTestRegistry(), zap.NewNop())
|
||||
spec, err := r.Resolve(context.Background(), runtime.RunRequest{
|
||||
RunID: "r-001",
|
||||
// Adapter 비워두면 default 선택
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if spec.Adapter != mock.Name {
|
||||
t.Errorf("Adapter: got %q, want %q", spec.Adapter, mock.Name)
|
||||
}
|
||||
if spec.RunID != "r-001" {
|
||||
t.Errorf("RunID: got %q, want %q", spec.RunID, "r-001")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_resolveExplicit(t *testing.T) {
|
||||
r := New(newTestRegistry(), zap.NewNop())
|
||||
spec, err := r.Resolve(context.Background(), runtime.RunRequest{
|
||||
RunID: "r-002",
|
||||
Adapter: mock.Name,
|
||||
Model: "mock-echo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if spec.Adapter != mock.Name {
|
||||
t.Errorf("Adapter: got %q, want %q", spec.Adapter, mock.Name)
|
||||
}
|
||||
if spec.Model != "mock-echo" {
|
||||
t.Errorf("Model: got %q, want %q", spec.Model, "mock-echo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_resolveUnknown(t *testing.T) {
|
||||
r := New(newTestRegistry(), zap.NewNop())
|
||||
_, err := r.Resolve(context.Background(), runtime.RunRequest{
|
||||
RunID: "r-003",
|
||||
Adapter: "nonexistent-adapter",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown adapter, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_emptyRegistry(t *testing.T) {
|
||||
r := New(adapters.NewRegistry(), zap.NewNop())
|
||||
_, err := r.Resolve(context.Background(), runtime.RunRequest{RunID: "r-004"})
|
||||
if err == nil {
|
||||
t.Error("expected error for empty registry, got nil")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**수정 파일 및 체크리스트**
|
||||
|
||||
- [ ] `apps/node/internal/router/router_test.go` 신규 생성
|
||||
- [ ] `newTestRegistry` 헬퍼 작성
|
||||
- [ ] `TestRouter_resolveDefault` 작성
|
||||
- [ ] `TestRouter_resolveExplicit` 작성
|
||||
- [ ] `TestRouter_resolveUnknown` 작성
|
||||
- [ ] `TestRouter_emptyRegistry` 작성
|
||||
|
||||
**테스트 작성**
|
||||
|
||||
이 항목 자체가 테스트 작성 작업.
|
||||
|
||||
**중간 검증**
|
||||
|
||||
```bash
|
||||
go test ./apps/node/internal/router/... -v
|
||||
```
|
||||
|
||||
기대 결과: 4개 테스트 PASS
|
||||
|
||||
---
|
||||
|
||||
## 의존 관계 및 구현 순서
|
||||
|
||||
TEST-1, TEST-2 는 외부 iop 패키지 의존성이 없어 독립 실행 가능.
|
||||
TEST-3 은 `go.uber.org/zap` 의존 → `go mod tidy` 완료 후 실행.
|
||||
TEST-4 는 mock 패키지 의존 → TEST-3 파일 존재 후 실행.
|
||||
|
||||
권장 순서: TEST-1 → TEST-2 → TEST-3 → TEST-4
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|------|------|
|
||||
| `apps/node/internal/transport/frame_test.go` | TEST-1 |
|
||||
| `apps/node/internal/transport/codec_test.go` | TEST-2 |
|
||||
| `apps/node/internal/adapters/mock/mock_test.go` | TEST-3 |
|
||||
| `apps/node/internal/router/router_test.go` | TEST-4 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
# 의존성 먼저 해결
|
||||
go mod tidy
|
||||
|
||||
# 전체 테스트
|
||||
go test ./apps/node/internal/transport/... \
|
||||
./apps/node/internal/adapters/mock/... \
|
||||
./apps/node/internal/router/... \
|
||||
-v -count=1
|
||||
|
||||
# 전체 빌드 깨짐 없음 확인
|
||||
go build ./...
|
||||
```
|
||||
|
||||
기대 결과: 전체 테스트 PASS, 빌드 에러 없음
|
||||
12
apps/control-plane/README.md
Normal file
12
apps/control-plane/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# iop-control-plane — Control Plane
|
||||
|
||||
노드 등록, 정책 적용, 작업 스케줄링을 담당한다.
|
||||
|
||||
**현재 상태: Placeholder**
|
||||
|
||||
## 계획된 기능
|
||||
|
||||
- 노드 등록 및 헬스체크
|
||||
- 정책 DSL 평가
|
||||
- 작업 스케줄링 (라운드로빈 / 최소 부하)
|
||||
- 노드 드레이닝 / 업그레이드 조율
|
||||
34
apps/control-plane/cmd/iop-control-plane/main.go
Normal file
34
apps/control-plane/cmd/iop-control-plane/main.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := rootCmd().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func rootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "iop-control-plane",
|
||||
Short: "IOP Control Plane — node registry, policy, scheduling (placeholder)",
|
||||
Long: `iop-control-plane manages node registration, enforces policy,
|
||||
and schedules jobs to nodes.
|
||||
|
||||
This component is a placeholder. Implementation is planned after the node is stable.`,
|
||||
}
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the control-plane server",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
fmt.Println("iop-control-plane serve: not yet implemented")
|
||||
},
|
||||
})
|
||||
return root
|
||||
}
|
||||
15
apps/edge/README.md
Normal file
15
apps/edge/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# iop-edge — Edge API Gateway
|
||||
|
||||
외부 클라이언트에 OpenAI-compatible HTTP API를 노출하고,
|
||||
IOP 내부 TCP 프로토콜을 통해 node로 요청을 라우팅한다.
|
||||
|
||||
**현재 상태: Placeholder**
|
||||
node 구현이 안정화된 이후 구현 예정.
|
||||
|
||||
## 계획된 기능
|
||||
|
||||
- `POST /v1/chat/completions` — OpenAI-compatible chat
|
||||
- `GET /v1/models` — 사용 가능한 모델 목록
|
||||
- 인증 (API Key / JWT)
|
||||
- 노드 선택 및 로드밸런싱
|
||||
- 스트리밍 SSE 프록시
|
||||
34
apps/edge/cmd/iop-edge/main.go
Normal file
34
apps/edge/cmd/iop-edge/main.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := rootCmd().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func rootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "iop-edge",
|
||||
Short: "IOP Edge — OpenAI-compatible API gateway (placeholder)",
|
||||
Long: `iop-edge exposes an OpenAI-compatible HTTP API and routes requests
|
||||
to IOP nodes via the TCP + Protobuf internal transport.
|
||||
|
||||
This component is a placeholder. Implementation is planned after the node is stable.`,
|
||||
}
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the edge server",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
fmt.Println("iop-edge serve: not yet implemented")
|
||||
},
|
||||
})
|
||||
return root
|
||||
}
|
||||
54
apps/node/README.md
Normal file
54
apps/node/README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# iop-node — Node Agent
|
||||
|
||||
디바이스당 1개 실행되는 IOP 노드 에이전트.
|
||||
실제 AI 모델 추론 어댑터를 관리하고, TCP + Protobuf + mTLS 소켓을 통해 요청을 처리한다.
|
||||
|
||||
## 내부 구조 (Hexagonal Architecture)
|
||||
|
||||
```
|
||||
cmd/iop-node/ — CLI 진입점 (cobra)
|
||||
internal/
|
||||
bootstrap/ — fx 의존성 주입 모듈
|
||||
node/ — 핵심 노드 서비스 (transport.Handler 구현)
|
||||
runtime/ — 도메인 타입: ExecutionSpec, RuntimeEvent, 인터페이스
|
||||
router/ — RunRequest → ExecutionSpec 라우팅
|
||||
transport/ — TCP 서버, 세션 관리, 프레임 코덱
|
||||
adapters/
|
||||
mock/ — 에코 테스트 어댑터
|
||||
ollama/ — Ollama API 어댑터 (TODO)
|
||||
vllm/ — vLLM OpenAI-compatible 어댑터 (TODO)
|
||||
cli/ — CLI 프로세스 어댑터 (claude/gemini/codex/opencode)
|
||||
store/ — SQLite 실행 이력
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
# 빌드
|
||||
go build -o bin/iop-node ./apps/node/cmd/iop-node
|
||||
|
||||
# 버전 확인
|
||||
./bin/iop-node version
|
||||
|
||||
# 설정 확인
|
||||
./bin/iop-node config print --config configs/node.yaml
|
||||
|
||||
# 서버 실행
|
||||
./bin/iop-node serve --config configs/node.yaml
|
||||
```
|
||||
|
||||
## 어댑터
|
||||
|
||||
| 어댑터 | 설명 | 상태 |
|
||||
|---|---|---|
|
||||
| `mock` | 입력 에코, 스트리밍 테스트용 | 구현 완료 |
|
||||
| `ollama` | 로컬 Ollama 서버 연동 | TODO |
|
||||
| `vllm` | vLLM OpenAI-compatible API | TODO |
|
||||
| `cli` | claude/gemini/codex/opencode CLI 실행 | 구현 완료 |
|
||||
|
||||
## Transport
|
||||
|
||||
- TCP 4-byte length-prefix framing
|
||||
- JSON-encoded Envelope (TODO: protobuf 교체)
|
||||
- mTLS (configs/node.yaml에서 활성화)
|
||||
- 하트비트: 30초 간격
|
||||
85
apps/node/cmd/iop-node/main.go
Normal file
85
apps/node/cmd/iop-node/main.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/fx"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"iop/apps/node/internal/bootstrap"
|
||||
"iop/packages/config"
|
||||
"iop/packages/version"
|
||||
)
|
||||
|
||||
var cfgFile string
|
||||
|
||||
func main() {
|
||||
if err := rootCmd().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func rootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "iop-node",
|
||||
Short: "IOP Node Agent — runs inference adapters on this device",
|
||||
}
|
||||
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "configs/node.yaml", "config file path")
|
||||
|
||||
root.AddCommand(serveCmd(), versionCmd(), configCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
func serveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the IOP node server",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, err := config.Load(cfgFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
app := fx.New(bootstrap.Module(cfg))
|
||||
app.Run()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func versionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print IOP node version",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
fmt.Println(version.Version)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func configCmd() *cobra.Command {
|
||||
cfgGroup := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Config management commands",
|
||||
}
|
||||
|
||||
printCmd := &cobra.Command{
|
||||
Use: "print",
|
||||
Short: "Print the resolved configuration",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
cfg, err := config.Load(cfgFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
enc := yaml.NewEncoder(os.Stdout)
|
||||
enc.SetIndent(2)
|
||||
return enc.Encode(cfg)
|
||||
},
|
||||
}
|
||||
|
||||
cfgGroup.AddCommand(printCmd)
|
||||
return cfgGroup
|
||||
}
|
||||
144
apps/node/internal/adapters/cli/cli.go
Normal file
144
apps/node/internal/adapters/cli/cli.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// Package cli provides an Adapter that runs external CLI tools as inference
|
||||
// backends. Profiles (claude, gemini, codex, opencode) are configured in
|
||||
// configs/node.yaml and select the command + args to execute.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"iop/apps/node/internal/runtime"
|
||||
"iop/packages/config"
|
||||
)
|
||||
|
||||
const Name = "cli"
|
||||
|
||||
// known profiles — must match CLI tool names
|
||||
var knownProfiles = []string{"claude", "gemini", "codex", "opencode"}
|
||||
|
||||
type CLI struct {
|
||||
profiles map[string]config.CLIProfileConf
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(cfg config.CLIConf, logger *zap.Logger) *CLI {
|
||||
return &CLI{
|
||||
profiles: cfg.Profiles,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CLI) Name() string { return Name }
|
||||
|
||||
func (c *CLI) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
||||
models := make([]string, 0, len(c.profiles))
|
||||
for name := range c.profiles {
|
||||
models = append(models, name)
|
||||
}
|
||||
// Always advertise known profiles even if not in config yet.
|
||||
for _, p := range knownProfiles {
|
||||
if _, ok := c.profiles[p]; !ok {
|
||||
models = append(models, p)
|
||||
}
|
||||
}
|
||||
return runtime.Capabilities{
|
||||
AdapterName: Name,
|
||||
Models: models,
|
||||
MaxConcurrency: 4,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
|
||||
profile, ok := c.profiles[spec.Model]
|
||||
if !ok {
|
||||
return fmt.Errorf("cli adapter: unknown profile %q", spec.Model)
|
||||
}
|
||||
|
||||
prompt := extractPrompt(spec.Input)
|
||||
args := append(append([]string{}, profile.Args...), prompt)
|
||||
cmd := exec.CommandContext(ctx, profile.Command, args...)
|
||||
|
||||
// Pass profile env vars
|
||||
if len(profile.Env) > 0 {
|
||||
cmd.Env = append(cmd.Environ(), profile.Env...)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cli adapter: stdout pipe: %w", err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cli adapter: stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("cli adapter: start %q: %w", profile.Command, err)
|
||||
}
|
||||
|
||||
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
||||
RunID: spec.RunID,
|
||||
Type: runtime.EventTypeStart,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
outputTokens := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
outputTokens += len(strings.Fields(line))
|
||||
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
||||
RunID: spec.RunID,
|
||||
Type: runtime.EventTypeDelta,
|
||||
Delta: line + "\n",
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Collect stderr for error reporting
|
||||
var errBuf strings.Builder
|
||||
errScanner := bufio.NewScanner(stderr)
|
||||
for errScanner.Scan() {
|
||||
errBuf.WriteString(errScanner.Text())
|
||||
errBuf.WriteByte('\n')
|
||||
}
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
||||
RunID: spec.RunID,
|
||||
Type: runtime.EventTypeError,
|
||||
Error: fmt.Sprintf("command failed: %v — %s", err, errBuf.String()),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
return fmt.Errorf("cli adapter: command exited with error: %w", err)
|
||||
}
|
||||
|
||||
return sink.Emit(ctx, runtime.RuntimeEvent{
|
||||
RunID: spec.RunID,
|
||||
Type: runtime.EventTypeComplete,
|
||||
Message: "cli execution complete",
|
||||
Usage: &runtime.UsageStats{
|
||||
InputTokens: len(strings.Fields(prompt)),
|
||||
OutputTokens: outputTokens,
|
||||
},
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func extractPrompt(input map[string]any) string {
|
||||
if input == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := input["prompt"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%v", input)
|
||||
}
|
||||
95
apps/node/internal/adapters/mock/mock.go
Normal file
95
apps/node/internal/adapters/mock/mock.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// Package mock provides a mock Adapter that echoes input with streaming deltas.
|
||||
// Useful for testing transport and pipeline correctness without real models.
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"iop/apps/node/internal/runtime"
|
||||
)
|
||||
|
||||
const Name = "mock"
|
||||
|
||||
type Mock struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(logger *zap.Logger) *Mock {
|
||||
return &Mock{logger: logger}
|
||||
}
|
||||
|
||||
func (m *Mock) Name() string { return Name }
|
||||
|
||||
func (m *Mock) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
||||
return runtime.Capabilities{
|
||||
AdapterName: Name,
|
||||
Models: []string{"mock-echo", "mock-stream"},
|
||||
MaxConcurrency: 16,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Mock) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
|
||||
m.logger.Info("mock adapter executing", zap.String("run_id", spec.RunID))
|
||||
|
||||
if err := sink.Emit(ctx, runtime.RuntimeEvent{
|
||||
RunID: spec.RunID,
|
||||
Type: runtime.EventTypeStart,
|
||||
Timestamp: time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prompt := extractPrompt(spec.Input)
|
||||
words := strings.Fields(fmt.Sprintf("echo: %s", prompt))
|
||||
if len(words) == 0 {
|
||||
words = []string{"(empty", "input)"}
|
||||
}
|
||||
|
||||
for _, word := range words {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
if err := sink.Emit(ctx, runtime.RuntimeEvent{
|
||||
RunID: spec.RunID,
|
||||
Type: runtime.EventTypeDelta,
|
||||
Delta: word + " ",
|
||||
Timestamp: time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
}
|
||||
|
||||
return sink.Emit(ctx, runtime.RuntimeEvent{
|
||||
RunID: spec.RunID,
|
||||
Type: runtime.EventTypeComplete,
|
||||
Message: "mock execution complete",
|
||||
Usage: &runtime.UsageStats{
|
||||
InputTokens: len(strings.Fields(prompt)),
|
||||
OutputTokens: len(words),
|
||||
},
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func extractPrompt(input map[string]any) string {
|
||||
if input == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := input["prompt"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if v, ok := input["messages"]; ok {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return fmt.Sprintf("%v", input)
|
||||
}
|
||||
48
apps/node/internal/adapters/ollama/ollama.go
Normal file
48
apps/node/internal/adapters/ollama/ollama.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Package ollama provides an Adapter that calls a local Ollama server.
|
||||
package ollama
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"iop/apps/node/internal/runtime"
|
||||
"iop/packages/config"
|
||||
)
|
||||
|
||||
const Name = "ollama"
|
||||
|
||||
type Ollama struct {
|
||||
baseURL string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(cfg config.OllamaConf, logger *zap.Logger) *Ollama {
|
||||
return &Ollama{
|
||||
baseURL: cfg.BaseURL,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Ollama) Name() string { return Name }
|
||||
|
||||
func (o *Ollama) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
||||
// TODO: query /api/tags from Ollama to enumerate available models.
|
||||
return runtime.Capabilities{
|
||||
AdapterName: Name,
|
||||
Models: []string{},
|
||||
MaxConcurrency: 4,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *Ollama) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
|
||||
// TODO: implement streaming call to Ollama /api/generate or /api/chat.
|
||||
// Use net/http with chunked JSON response (ndjson).
|
||||
o.logger.Info("ollama adapter called",
|
||||
zap.String("run_id", spec.RunID),
|
||||
zap.String("model", spec.Model),
|
||||
zap.String("base_url", o.baseURL),
|
||||
)
|
||||
return fmt.Errorf("ollama adapter: not yet implemented")
|
||||
}
|
||||
56
apps/node/internal/adapters/registry.go
Normal file
56
apps/node/internal/adapters/registry.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package adapters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"iop/apps/node/internal/runtime"
|
||||
)
|
||||
|
||||
// Registry holds all registered adapters by name.
|
||||
type Registry struct {
|
||||
adapters map[string]runtime.Adapter
|
||||
order []string // insertion order — first registered is the default
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{adapters: make(map[string]runtime.Adapter)}
|
||||
}
|
||||
|
||||
// Register adds an adapter. The first registered adapter becomes the default.
|
||||
func (r *Registry) Register(a runtime.Adapter) {
|
||||
name := a.Name()
|
||||
if _, exists := r.adapters[name]; !exists {
|
||||
r.order = append(r.order, name)
|
||||
}
|
||||
r.adapters[name] = a
|
||||
}
|
||||
|
||||
func (r *Registry) Get(name string) (runtime.Adapter, bool) {
|
||||
a, ok := r.adapters[name]
|
||||
return a, ok
|
||||
}
|
||||
|
||||
func (r *Registry) Default() string {
|
||||
if len(r.order) == 0 {
|
||||
return ""
|
||||
}
|
||||
return r.order[0]
|
||||
}
|
||||
|
||||
// All returns all registered adapters in registration order.
|
||||
func (r *Registry) All() []runtime.Adapter {
|
||||
out := make([]runtime.Adapter, 0, len(r.order))
|
||||
for _, name := range r.order {
|
||||
out = append(out, r.adapters[name])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MustGet returns the adapter or panics — useful during bootstrap validation.
|
||||
func (r *Registry) MustGet(name string) runtime.Adapter {
|
||||
a, ok := r.Get(name)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("adapter %q not registered", name))
|
||||
}
|
||||
return a
|
||||
}
|
||||
49
apps/node/internal/adapters/vllm/vllm.go
Normal file
49
apps/node/internal/adapters/vllm/vllm.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Package vllm provides an Adapter that calls a vLLM server via its
|
||||
// OpenAI-compatible /v1/chat/completions endpoint.
|
||||
package vllm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"iop/apps/node/internal/runtime"
|
||||
"iop/packages/config"
|
||||
)
|
||||
|
||||
const Name = "vllm"
|
||||
|
||||
type Vllm struct {
|
||||
endpoint string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(cfg config.VllmConf, logger *zap.Logger) *Vllm {
|
||||
return &Vllm{
|
||||
endpoint: cfg.Endpoint,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Vllm) Name() string { return Name }
|
||||
|
||||
func (v *Vllm) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
||||
// TODO: query /v1/models from vLLM endpoint.
|
||||
return runtime.Capabilities{
|
||||
AdapterName: Name,
|
||||
Models: []string{},
|
||||
MaxConcurrency: 8,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v *Vllm) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
|
||||
// TODO: implement streaming POST to v.endpoint/v1/chat/completions
|
||||
// with "stream": true and SSE response parsing.
|
||||
v.logger.Info("vllm adapter called",
|
||||
zap.String("run_id", spec.RunID),
|
||||
zap.String("model", spec.Model),
|
||||
zap.String("endpoint", v.endpoint),
|
||||
)
|
||||
return fmt.Errorf("vllm adapter: not yet implemented")
|
||||
}
|
||||
101
apps/node/internal/bootstrap/module.go
Normal file
101
apps/node/internal/bootstrap/module.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// Package bootstrap wires the IOP Node application using go.uber.org/fx.
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"iop/apps/node/internal/adapters"
|
||||
"iop/apps/node/internal/adapters/cli"
|
||||
"iop/apps/node/internal/adapters/mock"
|
||||
"iop/apps/node/internal/adapters/ollama"
|
||||
"iop/apps/node/internal/adapters/vllm"
|
||||
"iop/apps/node/internal/node"
|
||||
"iop/apps/node/internal/router"
|
||||
"iop/apps/node/internal/runtime"
|
||||
"iop/apps/node/internal/store"
|
||||
"iop/apps/node/internal/transport"
|
||||
"iop/packages/config"
|
||||
"iop/packages/observability"
|
||||
)
|
||||
|
||||
// Module returns the fx options that wire the node application.
|
||||
func Module(cfg *config.NodeConfig) fx.Option {
|
||||
return fx.Options(
|
||||
fx.Provide(
|
||||
func() *config.NodeConfig { return cfg },
|
||||
|
||||
func(cfg *config.NodeConfig) (*zap.Logger, error) {
|
||||
return observability.NewLogger(cfg.Logging.Level)
|
||||
},
|
||||
|
||||
func(cfg *config.NodeConfig, logger *zap.Logger) (*store.Store, error) {
|
||||
return store.New(cfg.SQLite.DSN, logger)
|
||||
},
|
||||
|
||||
func(cfg *config.NodeConfig, logger *zap.Logger) *adapters.Registry {
|
||||
reg := adapters.NewRegistry()
|
||||
|
||||
// mock adapter is always registered
|
||||
reg.Register(mock.New(logger))
|
||||
|
||||
if cfg.Adapters.Ollama.Enabled {
|
||||
reg.Register(ollama.New(cfg.Adapters.Ollama, logger))
|
||||
}
|
||||
if cfg.Adapters.Vllm.Enabled {
|
||||
reg.Register(vllm.New(cfg.Adapters.Vllm, logger))
|
||||
}
|
||||
if cfg.Adapters.CLI.Enabled {
|
||||
reg.Register(cli.New(cfg.Adapters.CLI, logger))
|
||||
}
|
||||
return reg
|
||||
},
|
||||
|
||||
func(reg *adapters.Registry, logger *zap.Logger) runtime.Router {
|
||||
return router.New(reg, logger)
|
||||
},
|
||||
|
||||
func(
|
||||
cfg *config.NodeConfig,
|
||||
r runtime.Router,
|
||||
reg *adapters.Registry,
|
||||
st *store.Store,
|
||||
logger *zap.Logger,
|
||||
) *node.Node {
|
||||
return node.New(cfg, r, reg, st, logger)
|
||||
},
|
||||
|
||||
func(n *node.Node, logger *zap.Logger) *transport.Server {
|
||||
return transport.NewServer(n, logger)
|
||||
},
|
||||
),
|
||||
|
||||
fx.Invoke(func(lc fx.Lifecycle, srv *transport.Server, cfg *config.NodeConfig, logger *zap.Logger) {
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
tlsCfg, err := transport.TLSFromConfig(cfg.TLS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tlsCfg != nil {
|
||||
srv.SetTLS(tlsCfg)
|
||||
}
|
||||
if err := srv.Start(ctx, cfg.Transport.Listen); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
if err := observability.ServeMetrics(cfg.Metrics.Port); err != nil {
|
||||
logger.Warn("metrics server exited", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
return srv.Stop()
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
169
apps/node/internal/node/node.go
Normal file
169
apps/node/internal/node/node.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// Package node is the core IOP Node service. It implements
|
||||
// transport.Handler and orchestrates routing → adapter execution.
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"iop/apps/node/internal/adapters"
|
||||
"iop/apps/node/internal/runtime"
|
||||
"iop/apps/node/internal/store"
|
||||
"iop/apps/node/internal/transport"
|
||||
"iop/packages/config"
|
||||
"iop/packages/protocol"
|
||||
)
|
||||
|
||||
// Node implements transport.Handler and coordinates the full execution pipeline.
|
||||
type Node struct {
|
||||
nodeID string
|
||||
router runtime.Router
|
||||
registry *adapters.Registry
|
||||
store *store.Store
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// New creates a Node. It satisfies transport.Handler.
|
||||
func New(
|
||||
cfg *config.NodeConfig,
|
||||
router runtime.Router,
|
||||
registry *adapters.Registry,
|
||||
st *store.Store,
|
||||
logger *zap.Logger,
|
||||
) *Node {
|
||||
return &Node{
|
||||
nodeID: cfg.Node.ID,
|
||||
router: router,
|
||||
registry: registry,
|
||||
store: st,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// OnRunRequest handles an incoming RunRequest from a transport Session.
|
||||
func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *protocol.RunRequest) error {
|
||||
n.logger.Info("run request received",
|
||||
zap.String("run_id", req.RunID),
|
||||
zap.String("adapter", req.Adapter),
|
||||
zap.String("model", req.Model),
|
||||
)
|
||||
|
||||
rr := runtime.RunRequest{
|
||||
RunID: req.RunID,
|
||||
Adapter: req.Adapter,
|
||||
Model: req.Model,
|
||||
Workspace: req.Workspace,
|
||||
Policy: req.Policy,
|
||||
Input: req.Input,
|
||||
TimeoutSec: req.TimeoutSec,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
|
||||
spec, err := n.router.Resolve(ctx, rr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("node: resolve: %w", err)
|
||||
}
|
||||
|
||||
adapter, ok := n.registry.Get(spec.Adapter)
|
||||
if !ok {
|
||||
return fmt.Errorf("node: adapter %q not found after routing", spec.Adapter)
|
||||
}
|
||||
|
||||
_ = n.store.InsertRun(ctx, store.RunRecord{
|
||||
RunID: spec.RunID,
|
||||
Adapter: spec.Adapter,
|
||||
Model: spec.Model,
|
||||
Status: "running",
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
execCtx := ctx
|
||||
if spec.TimeoutSec > 0 {
|
||||
var cancel context.CancelFunc
|
||||
execCtx, cancel = context.WithTimeout(ctx, time.Duration(spec.TimeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
sess.RegisterCancel(spec.RunID, cancel)
|
||||
defer sess.DeregisterCancel(spec.RunID)
|
||||
}
|
||||
|
||||
sink := &sessionSink{sess: sess, runID: spec.RunID}
|
||||
execErr := adapter.Execute(execCtx, spec, sink)
|
||||
|
||||
status := "completed"
|
||||
errMsg := ""
|
||||
if execErr != nil {
|
||||
status = "failed"
|
||||
errMsg = execErr.Error()
|
||||
n.logger.Warn("run failed", zap.String("run_id", spec.RunID), zap.Error(execErr))
|
||||
}
|
||||
_ = n.store.CompleteRun(context.Background(), spec.RunID, status, errMsg)
|
||||
return execErr
|
||||
}
|
||||
|
||||
// OnCapabilityRequest returns the node's available adapters.
|
||||
func (n *Node) OnCapabilityRequest(_ context.Context, _ *transport.Session) (*protocol.CapabilityResponse, error) {
|
||||
resp := &protocol.CapabilityResponse{NodeID: n.nodeID}
|
||||
for _, a := range n.registry.All() {
|
||||
caps, err := a.Capabilities(context.Background())
|
||||
if err != nil {
|
||||
n.logger.Warn("capabilities query failed", zap.String("adapter", a.Name()), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
resp.Adapters = append(resp.Adapters, protocol.AdapterInfo{
|
||||
Name: caps.AdapterName,
|
||||
Models: caps.Models,
|
||||
MaxConcurrency: caps.MaxConcurrency,
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// OnCancel cancels a running execution.
|
||||
func (n *Node) OnCancel(_ context.Context, sess *transport.Session, req *protocol.CancelRequest) error {
|
||||
n.logger.Info("cancel request", zap.String("run_id", req.RunID))
|
||||
sess.CancelRun(req.RunID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sessionSink wraps a transport.Session to implement runtime.EventSink.
|
||||
type sessionSink struct {
|
||||
sess *transport.Session
|
||||
runID string
|
||||
}
|
||||
|
||||
func (s *sessionSink) Emit(_ context.Context, event runtime.RuntimeEvent) error {
|
||||
var usageInfo *protocol.UsageInfo
|
||||
if event.Usage != nil {
|
||||
usageInfo = &protocol.UsageInfo{
|
||||
InputTokens: event.Usage.InputTokens,
|
||||
OutputTokens: event.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
|
||||
re := protocol.RunEvent{
|
||||
RunID: event.RunID,
|
||||
Type: string(event.Type),
|
||||
Delta: event.Delta,
|
||||
Message: event.Message,
|
||||
Error: event.Error,
|
||||
Usage: usageInfo,
|
||||
Metadata: event.Metadata,
|
||||
Timestamp: event.Timestamp.UnixNano(),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(re)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.sess.Send(&protocol.Envelope{
|
||||
ProtocolVersion: protocol.ProtocolVersion,
|
||||
RunID: s.runID,
|
||||
Type: protocol.TypeRunEvent,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
53
apps/node/internal/router/router.go
Normal file
53
apps/node/internal/router/router.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"iop/apps/node/internal/adapters"
|
||||
"iop/apps/node/internal/runtime"
|
||||
)
|
||||
|
||||
type defaultRouter struct {
|
||||
registry *adapters.Registry
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// New returns a Router that resolves requests to registered adapters.
|
||||
func New(reg *adapters.Registry, logger *zap.Logger) runtime.Router {
|
||||
return &defaultRouter{registry: reg, logger: logger}
|
||||
}
|
||||
|
||||
func (r *defaultRouter) Resolve(_ context.Context, req runtime.RunRequest) (runtime.ExecutionSpec, error) {
|
||||
adapterName := req.Adapter
|
||||
if adapterName == "" {
|
||||
adapterName = r.registry.Default()
|
||||
}
|
||||
if adapterName == "" {
|
||||
return runtime.ExecutionSpec{}, fmt.Errorf("router: no adapters registered")
|
||||
}
|
||||
|
||||
if _, ok := r.registry.Get(adapterName); !ok {
|
||||
return runtime.ExecutionSpec{}, fmt.Errorf("router: adapter %q not found", adapterName)
|
||||
}
|
||||
|
||||
spec := runtime.ExecutionSpec{
|
||||
RunID: req.RunID,
|
||||
Adapter: adapterName,
|
||||
Model: req.Model,
|
||||
Workspace: req.Workspace,
|
||||
Policy: req.Policy,
|
||||
Input: req.Input,
|
||||
TimeoutSec: req.TimeoutSec,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
|
||||
r.logger.Debug("resolved execution spec",
|
||||
zap.String("run_id", req.RunID),
|
||||
zap.String("adapter", spec.Adapter),
|
||||
zap.String("model", spec.Model),
|
||||
)
|
||||
return spec, nil
|
||||
}
|
||||
89
apps/node/internal/runtime/types.go
Normal file
89
apps/node/internal/runtime/types.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// Package runtime defines the core IOP node domain types and interfaces.
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ExecutionSpec is the resolved, policy-applied specification for a single run.
|
||||
type ExecutionSpec struct {
|
||||
RunID string
|
||||
Adapter string
|
||||
Model string
|
||||
Workspace string
|
||||
Policy map[string]any
|
||||
Input map[string]any
|
||||
TimeoutSec int
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// EventType classifies a RuntimeEvent.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventTypeStart EventType = "start"
|
||||
EventTypeDelta EventType = "delta"
|
||||
EventTypeComplete EventType = "complete"
|
||||
EventTypeError EventType = "error"
|
||||
)
|
||||
|
||||
// RuntimeEvent is a streaming execution event emitted by an Adapter.
|
||||
type RuntimeEvent struct {
|
||||
RunID string
|
||||
Type EventType
|
||||
Delta string
|
||||
Message string
|
||||
Error string
|
||||
Usage *UsageStats
|
||||
Metadata map[string]string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// UsageStats reports token consumption for a completed run.
|
||||
type UsageStats struct {
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
}
|
||||
|
||||
// Capabilities describes what an Adapter can do.
|
||||
type Capabilities struct {
|
||||
AdapterName string
|
||||
Models []string
|
||||
MaxConcurrency int
|
||||
}
|
||||
|
||||
// RunRequest is the node-domain representation of an incoming run request.
|
||||
type RunRequest struct {
|
||||
RunID string
|
||||
Adapter string
|
||||
Model string
|
||||
Workspace string
|
||||
Policy map[string]any
|
||||
Input map[string]any
|
||||
TimeoutSec int
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// EventSink receives RuntimeEvents emitted during execution.
|
||||
type EventSink interface {
|
||||
Emit(ctx context.Context, event RuntimeEvent) error
|
||||
}
|
||||
|
||||
// Adapter executes a model workload and streams events to a sink.
|
||||
type Adapter interface {
|
||||
Name() string
|
||||
Capabilities(ctx context.Context) (Capabilities, error)
|
||||
Execute(ctx context.Context, spec ExecutionSpec, sink EventSink) error
|
||||
}
|
||||
|
||||
// Router resolves a RunRequest into a concrete ExecutionSpec.
|
||||
type Router interface {
|
||||
Resolve(ctx context.Context, req RunRequest) (ExecutionSpec, error)
|
||||
}
|
||||
|
||||
// PolicyEngine applies and validates policies on execution specs.
|
||||
type PolicyEngine interface {
|
||||
Apply(ctx context.Context, spec ExecutionSpec) (ExecutionSpec, error)
|
||||
Validate(ctx context.Context, spec ExecutionSpec) error
|
||||
}
|
||||
100
apps/node/internal/store/store.go
Normal file
100
apps/node/internal/store/store.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
adapter TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
created_at DATETIME NOT NULL,
|
||||
completed_at DATETIME,
|
||||
error TEXT
|
||||
);
|
||||
`
|
||||
|
||||
// RunRecord holds the persisted state of an execution.
|
||||
type RunRecord struct {
|
||||
RunID string
|
||||
Adapter string
|
||||
Model string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
Error string
|
||||
}
|
||||
|
||||
// Store persists run history to SQLite.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// New opens (or creates) the SQLite database and runs the schema migration.
|
||||
func New(dsn string, logger *zap.Logger) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: open %s: %w", dsn, err)
|
||||
}
|
||||
db.SetMaxOpenConns(1) // SQLite is single-writer
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return nil, fmt.Errorf("store: migrate: %w", err)
|
||||
}
|
||||
logger.Info("store ready", zap.String("dsn", dsn))
|
||||
return &Store{db: db, logger: logger}, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying database connection.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// InsertRun records a new run.
|
||||
func (s *Store) InsertRun(ctx context.Context, r RunRecord) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO runs (run_id, adapter, model, status, created_at) VALUES (?,?,?,?,?)`,
|
||||
r.RunID, r.Adapter, r.Model, r.Status, r.CreatedAt.UTC(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// CompleteRun marks a run as completed or failed.
|
||||
func (s *Store) CompleteRun(ctx context.Context, runID, status, errMsg string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE runs SET status=?, completed_at=?, error=? WHERE run_id=?`,
|
||||
status, time.Now().UTC(), errMsg, runID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRun retrieves a run record by ID.
|
||||
func (s *Store) GetRun(ctx context.Context, runID string) (*RunRecord, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT run_id, adapter, model, status, created_at, completed_at, error FROM runs WHERE run_id=?`,
|
||||
runID,
|
||||
)
|
||||
var r RunRecord
|
||||
var completedAt sql.NullTime
|
||||
var errMsg sql.NullString
|
||||
if err := row.Scan(&r.RunID, &r.Adapter, &r.Model, &r.Status, &r.CreatedAt, &completedAt, &errMsg); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if completedAt.Valid {
|
||||
r.CompletedAt = &completedAt.Time
|
||||
}
|
||||
r.Error = errMsg.String
|
||||
return &r, nil
|
||||
}
|
||||
32
apps/node/internal/transport/codec.go
Normal file
32
apps/node/internal/transport/codec.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"iop/packages/protocol"
|
||||
)
|
||||
|
||||
// encodeEnvelope marshals env to JSON bytes (the frame payload).
|
||||
func encodeEnvelope(env *protocol.Envelope) ([]byte, error) {
|
||||
return json.Marshal(env)
|
||||
}
|
||||
|
||||
// decodeEnvelope unmarshals frame payload bytes into an Envelope.
|
||||
func decodeEnvelope(data []byte) (*protocol.Envelope, error) {
|
||||
var env protocol.Envelope
|
||||
if err := json.Unmarshal(data, &env); err != nil {
|
||||
return nil, fmt.Errorf("decode envelope: %w", err)
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// encodePayload marshals an inner message to JSON bytes for Envelope.Payload.
|
||||
func encodePayload(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// decodePayload unmarshals Envelope.Payload into dst.
|
||||
func decodePayload(payload []byte, dst any) error {
|
||||
return json.Unmarshal(payload, dst)
|
||||
}
|
||||
63
apps/node/internal/transport/frame.go
Normal file
63
apps/node/internal/transport/frame.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Package transport implements IOP's TCP framing layer.
|
||||
//
|
||||
// Wire format (same endianness as proto-socket):
|
||||
// [4 bytes big-endian uint32: payload length] [payload bytes]
|
||||
//
|
||||
// Payload is a JSON-encoded Envelope (see codec.go).
|
||||
// TODO: switch payload encoding to protobuf once proto/gen is available.
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
frameLenBytes = 4
|
||||
maxFrameSize = 64 << 20 // 64 MiB — same limit as proto-socket
|
||||
)
|
||||
|
||||
// readFrame reads one length-prefixed frame from r.
|
||||
func readFrame(r io.Reader) ([]byte, error) {
|
||||
hdr := make([]byte, frameLenBytes)
|
||||
if _, err := io.ReadFull(r, hdr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
length := binary.BigEndian.Uint32(hdr)
|
||||
if length == 0 {
|
||||
return []byte{}, nil
|
||||
}
|
||||
if length > maxFrameSize {
|
||||
return nil, fmt.Errorf("frame too large: %d bytes (max %d)", length, maxFrameSize)
|
||||
}
|
||||
buf := make([]byte, length)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// writeFrame writes payload as a length-prefixed frame to w.
|
||||
func writeFrame(w io.Writer, payload []byte) error {
|
||||
hdr := make([]byte, frameLenBytes)
|
||||
binary.BigEndian.PutUint32(hdr, uint32(len(payload)))
|
||||
if err := writeFull(w, hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFull(w, payload)
|
||||
}
|
||||
|
||||
func writeFull(w io.Writer, b []byte) error {
|
||||
for len(b) > 0 {
|
||||
n, err := w.Write(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
b = b[n:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
135
apps/node/internal/transport/server.go
Normal file
135
apps/node/internal/transport/server.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Server is the IOP TCP transport server.
|
||||
type Server struct {
|
||||
handler Handler
|
||||
logger *zap.Logger
|
||||
tlsConfig *tls.Config
|
||||
|
||||
listener net.Listener
|
||||
started atomic.Bool
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
nextID atomic.Uint64
|
||||
}
|
||||
|
||||
func NewServer(handler Handler, logger *zap.Logger) *Server {
|
||||
return &Server{
|
||||
handler: handler,
|
||||
logger: logger,
|
||||
sessions: make(map[string]*Session),
|
||||
}
|
||||
}
|
||||
|
||||
// SetTLS configures mTLS for the server. Must be called before Start.
|
||||
func (s *Server) SetTLS(cfg *tls.Config) {
|
||||
s.tlsConfig = cfg
|
||||
}
|
||||
|
||||
// Start begins accepting connections on addr.
|
||||
func (s *Server) Start(ctx context.Context, addr string) error {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("transport: listen %s: %w", addr, err)
|
||||
}
|
||||
if s.tlsConfig != nil {
|
||||
ln = tls.NewListener(ln, s.tlsConfig)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.listener = ln
|
||||
s.started.Store(true)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.logger.Info("transport listening",
|
||||
zap.String("addr", addr),
|
||||
zap.Bool("tls", s.tlsConfig != nil),
|
||||
)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = s.Stop()
|
||||
}()
|
||||
go s.acceptLoop(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop closes the listener and all active sessions.
|
||||
func (s *Server) Stop() error {
|
||||
if !s.started.CompareAndSwap(true, false) {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
ln := s.listener
|
||||
sessions := make([]*Session, 0, len(s.sessions))
|
||||
for _, sess := range s.sessions {
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
var err error
|
||||
if ln != nil {
|
||||
err = ln.Close()
|
||||
}
|
||||
for _, sess := range sessions {
|
||||
sess.Close()
|
||||
}
|
||||
s.logger.Info("transport stopped")
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionCount returns the number of active sessions.
|
||||
func (s *Server) SessionCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.sessions)
|
||||
}
|
||||
|
||||
func (s *Server) acceptLoop(ctx context.Context) {
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
if !s.started.Load() || errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
s.logger.Warn("accept error", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
go s.handleConn(ctx, conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleConn(ctx context.Context, conn net.Conn) {
|
||||
id := fmt.Sprintf("sess-%d", s.nextID.Add(1))
|
||||
sess := newSession(id, conn, s.handler, s.logger)
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[id] = sess
|
||||
s.mu.Unlock()
|
||||
|
||||
s.logger.Info("client connected",
|
||||
zap.String("session_id", id),
|
||||
zap.String("remote", conn.RemoteAddr().String()),
|
||||
)
|
||||
|
||||
sess.readLoop(ctx)
|
||||
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, id)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.logger.Info("client disconnected", zap.String("session_id", id))
|
||||
}
|
||||
228
apps/node/internal/transport/session.go
Normal file
228
apps/node/internal/transport/session.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"iop/packages/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
heartbeatInterval = 30 * time.Second
|
||||
heartbeatTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Handler handles decoded messages arriving on a Session.
|
||||
type Handler interface {
|
||||
OnRunRequest(ctx context.Context, sess *Session, req *protocol.RunRequest) error
|
||||
OnCapabilityRequest(ctx context.Context, sess *Session) (*protocol.CapabilityResponse, error)
|
||||
OnCancel(ctx context.Context, sess *Session, req *protocol.CancelRequest) error
|
||||
}
|
||||
|
||||
// Session represents one active TCP connection from a client.
|
||||
type Session struct {
|
||||
id string
|
||||
conn net.Conn
|
||||
handler Handler
|
||||
logger *zap.Logger
|
||||
|
||||
writeMu sync.Mutex
|
||||
closed atomic.Bool
|
||||
|
||||
cancelFns sync.Map // run_id → context.CancelFunc
|
||||
}
|
||||
|
||||
func newSession(id string, conn net.Conn, handler Handler, logger *zap.Logger) *Session {
|
||||
return &Session{
|
||||
id: id,
|
||||
conn: conn,
|
||||
handler: handler,
|
||||
logger: logger.With(zap.String("session_id", id)),
|
||||
}
|
||||
}
|
||||
|
||||
// ID returns the session identifier.
|
||||
func (s *Session) ID() string { return s.id }
|
||||
|
||||
// Send encodes env as a frame and writes it to the connection.
|
||||
func (s *Session) Send(env *protocol.Envelope) error {
|
||||
if s.closed.Load() {
|
||||
return fmt.Errorf("session %s: closed", s.id)
|
||||
}
|
||||
payload, err := encodeEnvelope(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
return writeFrame(s.conn, payload)
|
||||
}
|
||||
|
||||
// Close terminates the session.
|
||||
func (s *Session) Close() {
|
||||
if s.closed.CompareAndSwap(false, true) {
|
||||
s.conn.Close()
|
||||
s.logger.Info("session closed")
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterCancel stores a cancel function for a run, enabling CancelRequest handling.
|
||||
func (s *Session) RegisterCancel(runID string, cancel context.CancelFunc) {
|
||||
s.cancelFns.Store(runID, cancel)
|
||||
}
|
||||
|
||||
// DeregisterCancel removes a run's cancel function after completion.
|
||||
func (s *Session) DeregisterCancel(runID string) {
|
||||
s.cancelFns.Delete(runID)
|
||||
}
|
||||
|
||||
// CancelRun invokes the cancel function for a run if one is registered.
|
||||
func (s *Session) CancelRun(runID string) {
|
||||
if v, ok := s.cancelFns.Load(runID); ok {
|
||||
v.(context.CancelFunc)()
|
||||
}
|
||||
}
|
||||
|
||||
// readLoop drives the session until the connection closes or an error occurs.
|
||||
func (s *Session) readLoop(ctx context.Context) {
|
||||
defer s.Close()
|
||||
|
||||
// Perform capability handshake: server sends capability request,
|
||||
// client responds (optional — here we accept any client proactively).
|
||||
go s.heartbeatLoop(ctx)
|
||||
|
||||
for {
|
||||
if s.closed.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := readFrame(s.conn)
|
||||
if err != nil {
|
||||
if !s.closed.Load() {
|
||||
s.logger.Debug("read frame error", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
env, err := decodeEnvelope(raw)
|
||||
if err != nil {
|
||||
s.logger.Warn("decode envelope failed", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.dispatch(ctx, env); err != nil {
|
||||
s.logger.Warn("dispatch error", zap.String("type", string(env.Type)), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) dispatch(ctx context.Context, env *protocol.Envelope) error {
|
||||
switch env.Type {
|
||||
case protocol.TypeHeartbeat:
|
||||
return s.handleHeartbeat(env)
|
||||
|
||||
case protocol.TypeRunRequest:
|
||||
var req protocol.RunRequest
|
||||
if err := decodePayload(env.Payload, &req); err != nil {
|
||||
return s.sendError(env.RequestID, "DECODE_ERROR", err.Error())
|
||||
}
|
||||
go func() {
|
||||
if err := s.handler.OnRunRequest(ctx, s, &req); err != nil {
|
||||
_ = s.sendError(env.RequestID, "RUN_ERROR", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
case protocol.TypeCapabilityRequest:
|
||||
resp, err := s.handler.OnCapabilityRequest(ctx, s)
|
||||
if err != nil {
|
||||
return s.sendError(env.RequestID, "CAPABILITY_ERROR", err.Error())
|
||||
}
|
||||
return s.sendCapabilityResponse(env.RequestID, resp)
|
||||
|
||||
case protocol.TypeCancelRequest:
|
||||
var req protocol.CancelRequest
|
||||
if err := decodePayload(env.Payload, &req); err != nil {
|
||||
return s.sendError(env.RequestID, "DECODE_ERROR", err.Error())
|
||||
}
|
||||
return s.handler.OnCancel(ctx, s, &req)
|
||||
|
||||
default:
|
||||
s.logger.Warn("unknown message type", zap.String("type", string(env.Type)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) handleHeartbeat(env *protocol.Envelope) error {
|
||||
// Echo heartbeat back
|
||||
resp, err := encodePayload(protocol.Heartbeat{Timestamp: time.Now().UnixNano()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Send(&protocol.Envelope{
|
||||
ProtocolVersion: protocol.ProtocolVersion,
|
||||
RequestID: env.RequestID,
|
||||
Type: protocol.TypeHeartbeat,
|
||||
Payload: resp,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) sendError(requestID, code, message string) error {
|
||||
payload, err := encodePayload(protocol.ErrorMsg{Code: code, Message: message})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Send(&protocol.Envelope{
|
||||
ProtocolVersion: protocol.ProtocolVersion,
|
||||
RequestID: requestID,
|
||||
Type: protocol.TypeError,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) sendCapabilityResponse(requestID string, resp *protocol.CapabilityResponse) error {
|
||||
payload, err := encodePayload(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Send(&protocol.Envelope{
|
||||
ProtocolVersion: protocol.ProtocolVersion,
|
||||
RequestID: requestID,
|
||||
Type: protocol.TypeCapabilityResponse,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
// heartbeatLoop periodically sends heartbeat frames to detect dead connections.
|
||||
func (s *Session) heartbeatLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(heartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if s.closed.Load() {
|
||||
return
|
||||
}
|
||||
payload, _ := encodePayload(protocol.Heartbeat{Timestamp: time.Now().UnixNano()})
|
||||
if err := s.Send(&protocol.Envelope{
|
||||
ProtocolVersion: protocol.ProtocolVersion,
|
||||
Type: protocol.TypeHeartbeat,
|
||||
Payload: payload,
|
||||
}); err != nil {
|
||||
s.logger.Debug("heartbeat send failed", zap.Error(err))
|
||||
s.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
apps/node/internal/transport/tls.go
Normal file
17
apps/node/internal/transport/tls.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package transport
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
|
||||
"iop/packages/auth"
|
||||
"iop/packages/config"
|
||||
)
|
||||
|
||||
// TLSFromConfig builds a server *tls.Config from NodeConfig.TLS.
|
||||
// Returns nil if TLS is disabled.
|
||||
func TLSFromConfig(cfg config.TLSConf) (*tls.Config, error) {
|
||||
if !cfg.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
return auth.LoadServerTLS(cfg.Cert, cfg.Key, cfg.CA)
|
||||
}
|
||||
11
apps/worker/README.md
Normal file
11
apps/worker/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# iop-worker — Async Worker
|
||||
|
||||
비동기 작업 큐에서 작업을 꺼내어 처리한다.
|
||||
|
||||
**현재 상태: Placeholder**
|
||||
|
||||
## 계획된 기능
|
||||
|
||||
- 작업 큐 소비 (SQLite / Redis)
|
||||
- 재시도 정책
|
||||
- 결과 저장 및 콜백
|
||||
33
apps/worker/cmd/iop-worker/main.go
Normal file
33
apps/worker/cmd/iop-worker/main.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := rootCmd().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func rootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "iop-worker",
|
||||
Short: "IOP Worker — async job processor (placeholder)",
|
||||
Long: `iop-worker processes asynchronous jobs from the IOP job queue.
|
||||
|
||||
This component is a placeholder. Implementation is planned after the node is stable.`,
|
||||
}
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the worker",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
fmt.Println("iop-worker serve: not yet implemented")
|
||||
},
|
||||
})
|
||||
return root
|
||||
}
|
||||
11
configs/control-plane.yaml
Normal file
11
configs/control-plane.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
server:
|
||||
listen: "0.0.0.0:9080"
|
||||
|
||||
tls:
|
||||
enabled: false
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
|
||||
metrics:
|
||||
port: 9093
|
||||
11
configs/edge.yaml
Normal file
11
configs/edge.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
server:
|
||||
listen: "0.0.0.0:8080"
|
||||
|
||||
tls:
|
||||
enabled: false
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
|
||||
metrics:
|
||||
port: 9092
|
||||
57
configs/node.yaml
Normal file
57
configs/node.yaml
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
node:
|
||||
id: "node-001"
|
||||
name: "iop-node-local"
|
||||
|
||||
transport:
|
||||
listen: "0.0.0.0:9090"
|
||||
|
||||
tls:
|
||||
enabled: false
|
||||
cert: "certs/node.crt"
|
||||
key: "certs/node.key"
|
||||
ca: "certs/ca.crt"
|
||||
|
||||
runtime:
|
||||
concurrency: 4
|
||||
workspace_root: "/tmp/iop/workspace"
|
||||
|
||||
sqlite:
|
||||
dsn: "file:iop.db?cache=shared&mode=rwc"
|
||||
|
||||
logging:
|
||||
level: "info" # debug | info | warn | error
|
||||
|
||||
metrics:
|
||||
port: 9091
|
||||
|
||||
adapters:
|
||||
ollama:
|
||||
enabled: false
|
||||
base_url: "http://localhost:11434"
|
||||
|
||||
vllm:
|
||||
enabled: false
|
||||
endpoint: "http://localhost:8000"
|
||||
|
||||
cli:
|
||||
enabled: false
|
||||
profiles:
|
||||
claude:
|
||||
command: "claude"
|
||||
args: []
|
||||
env: []
|
||||
|
||||
gemini:
|
||||
command: "gemini"
|
||||
args: []
|
||||
env: []
|
||||
|
||||
codex:
|
||||
command: "codex"
|
||||
args: []
|
||||
env: []
|
||||
|
||||
opencode:
|
||||
command: "opencode"
|
||||
args: []
|
||||
env: []
|
||||
11
configs/worker.yaml
Normal file
11
configs/worker.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
worker:
|
||||
concurrency: 8
|
||||
|
||||
sqlite:
|
||||
dsn: "file:iop-worker.db?cache=shared&mode=rwc"
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
|
||||
metrics:
|
||||
port: 9094
|
||||
129
docs/architecture.md
Normal file
129
docs/architecture.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# IOP Architecture
|
||||
|
||||
## 개요
|
||||
|
||||
IOP(Inference Operations Platform)는 분산 AI 모델 추론을 위한 플랫폼이다.
|
||||
단일 서비스가 아니라 협력하는 여러 앱으로 구성된다.
|
||||
|
||||
```
|
||||
Client (OpenAI SDK)
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ edge │ OpenAI-compatible HTTP API
|
||||
└────┬────┘
|
||||
│ IOP TCP + Protobuf + mTLS
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ control-plane │ 노드 등록 / 정책 / 스케줄링
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌─────┴──────┐
|
||||
▼ ▼
|
||||
┌──────┐ ┌──────┐ (N개의 node)
|
||||
│ node │ │ node │
|
||||
└──┬───┘ └──┬───┘
|
||||
│ │
|
||||
┌──┴──┐ ┌──┴──┐
|
||||
│mock │ │ollama│ 어댑터
|
||||
│cli │ │vllm │
|
||||
└─────┘ └──────┘
|
||||
|
||||
┌────────┐
|
||||
│ worker │ 비동기 작업 처리
|
||||
└────────┘
|
||||
```
|
||||
|
||||
## 내부 통신 프로토콜
|
||||
|
||||
### 와이어 포맷
|
||||
|
||||
```
|
||||
Frame: [4 bytes big-endian uint32: length] [payload]
|
||||
Payload: JSON-encoded Envelope
|
||||
Envelope.Payload: JSON-encoded inner message
|
||||
```
|
||||
|
||||
**TODO**: Payload 인코딩을 protobuf로 교체 (proto/iop/runtime.proto 참고)
|
||||
|
||||
### 메시지 타입
|
||||
|
||||
| Type | 방향 | 설명 |
|
||||
|------|------|------|
|
||||
| `run.request` | client → node | 추론 실행 요청 |
|
||||
| `run.event` | node → client | 스트리밍 이벤트 (start/delta/complete/error) |
|
||||
| `heartbeat` | 양방향 | 연결 유지 (30초 간격) |
|
||||
| `cancel.request` | client → node | 실행 중단 요청 |
|
||||
| `capability.request` | client → node | 어댑터 목록 요청 |
|
||||
| `capability.response` | node → client | 어댑터 정보 응답 |
|
||||
| `error` | node → client | 에러 응답 |
|
||||
|
||||
### mTLS
|
||||
|
||||
```yaml
|
||||
tls:
|
||||
enabled: true
|
||||
cert: certs/node.crt
|
||||
key: certs/node.key
|
||||
ca: certs/ca.crt
|
||||
```
|
||||
|
||||
`tls.enabled: true`로 설정하면 RequireAndVerifyClientCert (mTLS) 모드로 동작.
|
||||
|
||||
## apps/node 내부 구조
|
||||
|
||||
```
|
||||
Hexagonal Architecture
|
||||
|
||||
┌───────────────────────────────┐
|
||||
│ node.Node │ (application core)
|
||||
│ │
|
||||
TCP │ transport.Handler interface │
|
||||
─────────► OnRunRequest() │
|
||||
client │ OnCapabilityRequest() │
|
||||
│ OnCancel() │
|
||||
└───────┬───────────────────────┘
|
||||
│
|
||||
router.Resolve() → ExecutionSpec
|
||||
│
|
||||
adapter.Execute() → RuntimeEvent stream
|
||||
│
|
||||
store.CompleteRun() → SQLite
|
||||
```
|
||||
|
||||
### 어댑터 패턴
|
||||
|
||||
```go
|
||||
type Adapter interface {
|
||||
Name() string
|
||||
Capabilities(ctx) (Capabilities, error)
|
||||
Execute(ctx, spec ExecutionSpec, sink EventSink) error
|
||||
}
|
||||
```
|
||||
|
||||
새 어댑터를 추가하려면:
|
||||
1. `apps/node/internal/adapters/<name>/` 패키지 생성
|
||||
2. `runtime.Adapter` 인터페이스 구현
|
||||
3. `bootstrap/module.go`에서 Registry에 등록
|
||||
|
||||
## 라이브러리 선택
|
||||
|
||||
| 라이브러리 | 역할 |
|
||||
|------------|------|
|
||||
| `go.uber.org/fx` | 의존성 주입 |
|
||||
| `go.uber.org/zap` | 구조화 로깅 |
|
||||
| `github.com/spf13/cobra` | CLI |
|
||||
| `github.com/spf13/viper` | 설정 파일 |
|
||||
| `google.golang.org/protobuf` | Protobuf (spec용) |
|
||||
| `github.com/prometheus/client_golang` | 메트릭 |
|
||||
| `modernc.org/sqlite` | 실행 이력 저장 |
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- gRPC (`grpc-go`) 사용 금지
|
||||
- WebSocket 기본 transport 사용 금지
|
||||
- Actor framework 사용 금지
|
||||
- External worker pool 사용 금지
|
||||
- FSM framework 사용 금지
|
||||
- Plugin framework 사용 금지
|
||||
- Kubernetes 의존성 금지
|
||||
14
go.mod
Normal file
14
go.mod
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
module iop
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/viper v1.19.0
|
||||
go.uber.org/fx v1.22.2
|
||||
go.uber.org/zap v1.27.0
|
||||
google.golang.org/protobuf v1.36.5
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.33.1
|
||||
)
|
||||
58
packages/auth/auth.go
Normal file
58
packages/auth/auth.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Package auth provides mTLS certificate helpers for IOP transport.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// LoadServerTLS builds a *tls.Config for the IOP TCP server with mTLS.
|
||||
// cert, key: server certificate and private key paths.
|
||||
// ca: path to the CA certificate that signs client certificates.
|
||||
func LoadServerTLS(cert, key, ca string) (*tls.Config, error) {
|
||||
serverCert, err := tls.LoadX509KeyPair(cert, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load server cert: %w", err)
|
||||
}
|
||||
|
||||
caCert, err := os.ReadFile(ca)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CA cert: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caCert) {
|
||||
return nil, fmt.Errorf("failed to parse CA cert")
|
||||
}
|
||||
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{serverCert},
|
||||
ClientCAs: pool,
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadClientTLS builds a *tls.Config for an IOP TCP client with mTLS.
|
||||
func LoadClientTLS(cert, key, ca string) (*tls.Config, error) {
|
||||
clientCert, err := tls.LoadX509KeyPair(cert, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load client cert: %w", err)
|
||||
}
|
||||
|
||||
caCert, err := os.ReadFile(ca)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CA cert: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caCert) {
|
||||
return nil, fmt.Errorf("failed to parse CA cert")
|
||||
}
|
||||
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{clientCert},
|
||||
RootCAs: pool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}, nil
|
||||
}
|
||||
100
packages/config/config.go
Normal file
100
packages/config/config.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type NodeConfig struct {
|
||||
Node NodeInfo `mapstructure:"node" yaml:"node"`
|
||||
Transport TransportConf `mapstructure:"transport" yaml:"transport"`
|
||||
TLS TLSConf `mapstructure:"tls" yaml:"tls"`
|
||||
Runtime RuntimeConf `mapstructure:"runtime" yaml:"runtime"`
|
||||
SQLite SQLiteConf `mapstructure:"sqlite" yaml:"sqlite"`
|
||||
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
|
||||
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
|
||||
Adapters AdaptersConf `mapstructure:"adapters" yaml:"adapters"`
|
||||
}
|
||||
|
||||
type NodeInfo struct {
|
||||
ID string `mapstructure:"id" yaml:"id"`
|
||||
Name string `mapstructure:"name" yaml:"name"`
|
||||
}
|
||||
|
||||
type TransportConf struct {
|
||||
Listen string `mapstructure:"listen" yaml:"listen"`
|
||||
}
|
||||
|
||||
type TLSConf struct {
|
||||
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
||||
Cert string `mapstructure:"cert" yaml:"cert"`
|
||||
Key string `mapstructure:"key" yaml:"key"`
|
||||
CA string `mapstructure:"ca" yaml:"ca"`
|
||||
}
|
||||
|
||||
type RuntimeConf struct {
|
||||
Concurrency int `mapstructure:"concurrency" yaml:"concurrency"`
|
||||
WorkspaceRoot string `mapstructure:"workspace_root" yaml:"workspace_root"`
|
||||
}
|
||||
|
||||
type SQLiteConf struct {
|
||||
DSN string `mapstructure:"dsn" yaml:"dsn"`
|
||||
}
|
||||
|
||||
type LoggingConf struct {
|
||||
Level string `mapstructure:"level" yaml:"level"`
|
||||
}
|
||||
|
||||
type MetricsConf struct {
|
||||
Port int `mapstructure:"port" yaml:"port"`
|
||||
}
|
||||
|
||||
type AdaptersConf struct {
|
||||
Ollama OllamaConf `mapstructure:"ollama" yaml:"ollama"`
|
||||
Vllm VllmConf `mapstructure:"vllm" yaml:"vllm"`
|
||||
CLI CLIConf `mapstructure:"cli" yaml:"cli"`
|
||||
}
|
||||
|
||||
type OllamaConf struct {
|
||||
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
||||
BaseURL string `mapstructure:"base_url" yaml:"base_url"`
|
||||
}
|
||||
|
||||
type VllmConf struct {
|
||||
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
||||
Endpoint string `mapstructure:"endpoint" yaml:"endpoint"`
|
||||
}
|
||||
|
||||
type CLIConf struct {
|
||||
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
||||
Profiles map[string]CLIProfileConf `mapstructure:"profiles" yaml:"profiles"`
|
||||
}
|
||||
|
||||
type CLIProfileConf struct {
|
||||
Command string `mapstructure:"command" yaml:"command"`
|
||||
Args []string `mapstructure:"args" yaml:"args"`
|
||||
Env []string `mapstructure:"env" yaml:"env"`
|
||||
}
|
||||
|
||||
func Load(cfgFile string) (*NodeConfig, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(cfgFile)
|
||||
setDefaults(v)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg NodeConfig
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("transport.listen", "0.0.0.0:9090")
|
||||
v.SetDefault("runtime.concurrency", 4)
|
||||
v.SetDefault("runtime.workspace_root", "/tmp/iop/workspace")
|
||||
v.SetDefault("sqlite.dsn", "file:iop.db?cache=shared&mode=rwc")
|
||||
v.SetDefault("logging.level", "info")
|
||||
v.SetDefault("metrics.port", 9091)
|
||||
v.SetDefault("tls.enabled", false)
|
||||
}
|
||||
28
packages/jobs/jobs.go
Normal file
28
packages/jobs/jobs.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package jobs
|
||||
|
||||
import "time"
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusRunning Status = "running"
|
||||
StatusCompleted Status = "completed"
|
||||
StatusFailed Status = "failed"
|
||||
StatusCancelled Status = "cancelled"
|
||||
)
|
||||
|
||||
// Job represents a unit of work tracked by the system.
|
||||
type Job struct {
|
||||
JobID string `json:"job_id"`
|
||||
RunID string `json:"run_id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Adapter string `json:"adapter"`
|
||||
Model string `json:"model"`
|
||||
Status Status `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
30
packages/metadata/metadata.go
Normal file
30
packages/metadata/metadata.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package metadata
|
||||
|
||||
// M is a string key-value metadata map used throughout IOP.
|
||||
type M map[string]string
|
||||
|
||||
func (m M) Get(key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[key]
|
||||
}
|
||||
|
||||
func (m M) Set(key, value string) M {
|
||||
if m == nil {
|
||||
m = make(M)
|
||||
}
|
||||
m[key] = value
|
||||
return m
|
||||
}
|
||||
|
||||
func (m M) Merge(other M) M {
|
||||
out := make(M, len(m)+len(other))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range other {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
34
packages/observability/observability.go
Normal file
34
packages/observability/observability.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package observability
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// NewLogger creates a zap production logger at the given level.
|
||||
func NewLogger(level string) (*zap.Logger, error) {
|
||||
cfg := zap.NewProductionConfig()
|
||||
lvl, err := zapcore.ParseLevel(level)
|
||||
if err != nil {
|
||||
lvl = zapcore.InfoLevel
|
||||
}
|
||||
cfg.Level = zap.NewAtomicLevelAt(lvl)
|
||||
return cfg.Build()
|
||||
}
|
||||
|
||||
// ServeMetrics starts a Prometheus /metrics endpoint on the given port.
|
||||
// It blocks until the server exits.
|
||||
func ServeMetrics(port int) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
return http.ListenAndServe(addr, mux)
|
||||
}
|
||||
25
packages/policy/engine.go
Normal file
25
packages/policy/engine.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package policy
|
||||
|
||||
import "context"
|
||||
|
||||
// Spec is a policy specification map passed in ExecutionSpec.Policy.
|
||||
type Spec map[string]any
|
||||
|
||||
// Engine applies and validates policies on execution specs.
|
||||
type Engine interface {
|
||||
// Apply enriches or modifies spec fields based on policy rules.
|
||||
Apply(ctx context.Context, adapter, model string, policy Spec) (Spec, error)
|
||||
// Validate returns an error if the policy rejects the request.
|
||||
Validate(ctx context.Context, adapter, model string, policy Spec) error
|
||||
}
|
||||
|
||||
// Passthrough is a no-op policy engine used when no policy is configured.
|
||||
type Passthrough struct{}
|
||||
|
||||
func (Passthrough) Apply(_ context.Context, _, _ string, policy Spec) (Spec, error) {
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (Passthrough) Validate(_ context.Context, _, _ string, _ Spec) error {
|
||||
return nil
|
||||
}
|
||||
92
packages/protocol/protocol.go
Normal file
92
packages/protocol/protocol.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Package protocol defines the IOP wire-level message types.
|
||||
// The transport layer uses 4-byte big-endian length-prefixed frames.
|
||||
// Each frame carries a JSON-encoded Envelope; Envelope.Payload holds
|
||||
// the JSON-encoded inner message (RunRequest, RunEvent, etc.).
|
||||
//
|
||||
// TODO: replace JSON encoding with protobuf once proto/iop/runtime.proto
|
||||
// is compiled with protoc-gen-go.
|
||||
package protocol
|
||||
|
||||
const ProtocolVersion uint32 = 1
|
||||
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
TypeRunRequest MessageType = "run.request"
|
||||
TypeRunEvent MessageType = "run.event"
|
||||
TypeHeartbeat MessageType = "heartbeat"
|
||||
TypeCancelRequest MessageType = "cancel.request"
|
||||
TypeError MessageType = "error"
|
||||
TypeCapabilityRequest MessageType = "capability.request"
|
||||
TypeCapabilityResponse MessageType = "capability.response"
|
||||
)
|
||||
|
||||
// Envelope is the outer wrapper for every IOP TCP message.
|
||||
type Envelope struct {
|
||||
ProtocolVersion uint32 `json:"protocol_version"`
|
||||
RequestID string `json:"request_id"`
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
StreamID string `json:"stream_id,omitempty"`
|
||||
Type MessageType `json:"type"`
|
||||
Payload []byte `json:"payload"`
|
||||
}
|
||||
|
||||
// RunRequest initiates a model execution on the node.
|
||||
type RunRequest struct {
|
||||
RunID string `json:"run_id"`
|
||||
Adapter string `json:"adapter,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Workspace string `json:"workspace,omitempty"`
|
||||
Policy map[string]any `json:"policy,omitempty"`
|
||||
Input map[string]any `json:"input"`
|
||||
TimeoutSec int `json:"timeout_sec,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// RunEvent is a streaming execution event sent from node to client.
|
||||
type RunEvent struct {
|
||||
RunID string `json:"run_id"`
|
||||
Type string `json:"type"` // start | delta | complete | error
|
||||
Delta string `json:"delta,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Usage *UsageInfo `json:"usage,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"` // unix nano
|
||||
}
|
||||
|
||||
type UsageInfo struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// Heartbeat keeps the TCP connection alive.
|
||||
type Heartbeat struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// CancelRequest asks the node to cancel a running execution.
|
||||
type CancelRequest struct {
|
||||
RunID string `json:"run_id"`
|
||||
}
|
||||
|
||||
// ErrorMsg is returned when a request fails at the transport layer.
|
||||
type ErrorMsg struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// CapabilityRequest asks the node what adapters/models it supports.
|
||||
type CapabilityRequest struct{}
|
||||
|
||||
// CapabilityResponse lists the node's available adapters.
|
||||
type CapabilityResponse struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Adapters []AdapterInfo `json:"adapters"`
|
||||
}
|
||||
|
||||
type AdapterInfo struct {
|
||||
Name string `json:"name"`
|
||||
Models []string `json:"models"`
|
||||
MaxConcurrency int `json:"max_concurrency"`
|
||||
}
|
||||
3
packages/version/version.go
Normal file
3
packages/version/version.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package version
|
||||
|
||||
const Version = "0.1.0"
|
||||
27
proto/iop/control.proto
Normal file
27
proto/iop/control.proto
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package iop;
|
||||
|
||||
option go_package = "iop/proto/gen/iop";
|
||||
|
||||
// PolicyRule defines a routing/resource constraint.
|
||||
message PolicyRule {
|
||||
string name = 1;
|
||||
string expression = 2;
|
||||
map<string, string> params = 3;
|
||||
}
|
||||
|
||||
// ScheduleRequest asks the control-plane to route a job to a node.
|
||||
message ScheduleRequest {
|
||||
string job_id = 1;
|
||||
string model = 2;
|
||||
repeated PolicyRule policies = 3;
|
||||
map<string, string> metadata = 4;
|
||||
}
|
||||
|
||||
// ScheduleResponse contains the selected node address.
|
||||
message ScheduleResponse {
|
||||
string node_id = 1;
|
||||
string address = 2;
|
||||
string token = 3;
|
||||
}
|
||||
39
proto/iop/job.proto
Normal file
39
proto/iop/job.proto
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package iop;
|
||||
|
||||
option go_package = "iop/proto/gen/iop";
|
||||
|
||||
enum JobStatus {
|
||||
JOB_STATUS_UNSPECIFIED = 0;
|
||||
JOB_STATUS_PENDING = 1;
|
||||
JOB_STATUS_RUNNING = 2;
|
||||
JOB_STATUS_COMPLETED = 3;
|
||||
JOB_STATUS_FAILED = 4;
|
||||
JOB_STATUS_CANCELLED = 5;
|
||||
}
|
||||
|
||||
message Job {
|
||||
string job_id = 1;
|
||||
string run_id = 2;
|
||||
string node_id = 3;
|
||||
string adapter = 4;
|
||||
string model = 5;
|
||||
JobStatus status = 6;
|
||||
int64 created_at = 7;
|
||||
int64 started_at = 8;
|
||||
int64 finished_at = 9;
|
||||
string error = 10;
|
||||
map<string, string> metadata = 11;
|
||||
}
|
||||
|
||||
message JobListRequest {
|
||||
JobStatus status_filter = 1;
|
||||
int32 limit = 2;
|
||||
string cursor = 3;
|
||||
}
|
||||
|
||||
message JobListResponse {
|
||||
repeated Job jobs = 1;
|
||||
string next = 2;
|
||||
}
|
||||
34
proto/iop/node.proto
Normal file
34
proto/iop/node.proto
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package iop;
|
||||
|
||||
option go_package = "iop/proto/gen/iop";
|
||||
|
||||
// NodeInfo describes a registered node.
|
||||
message NodeInfo {
|
||||
string node_id = 1;
|
||||
string name = 2;
|
||||
string address = 3;
|
||||
string version = 4;
|
||||
NodeStatus status = 5;
|
||||
map<string, string> labels = 6;
|
||||
}
|
||||
|
||||
enum NodeStatus {
|
||||
NODE_STATUS_UNSPECIFIED = 0;
|
||||
NODE_STATUS_ONLINE = 1;
|
||||
NODE_STATUS_OFFLINE = 2;
|
||||
NODE_STATUS_DRAINING = 3;
|
||||
}
|
||||
|
||||
// NodeRegisterRequest is sent to the control-plane on startup.
|
||||
message NodeRegisterRequest {
|
||||
NodeInfo info = 1;
|
||||
}
|
||||
|
||||
// NodeRegisterResponse is returned by the control-plane.
|
||||
message NodeRegisterResponse {
|
||||
bool accepted = 1;
|
||||
string token = 2;
|
||||
string reason = 3;
|
||||
}
|
||||
86
proto/iop/runtime.proto
Normal file
86
proto/iop/runtime.proto
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package iop;
|
||||
|
||||
option go_package = "iop/proto/gen/iop";
|
||||
|
||||
// Envelope wraps every IOP TCP message.
|
||||
message Envelope {
|
||||
uint32 protocol_version = 1;
|
||||
string request_id = 2;
|
||||
string run_id = 3;
|
||||
string stream_id = 4;
|
||||
MessageType type = 5;
|
||||
bytes payload = 6; // serialized inner message
|
||||
}
|
||||
|
||||
enum MessageType {
|
||||
MESSAGE_TYPE_UNSPECIFIED = 0;
|
||||
MESSAGE_TYPE_RUN_REQUEST = 1;
|
||||
MESSAGE_TYPE_RUN_EVENT = 2;
|
||||
MESSAGE_TYPE_HEARTBEAT = 3;
|
||||
MESSAGE_TYPE_CANCEL_REQUEST = 4;
|
||||
MESSAGE_TYPE_ERROR = 5;
|
||||
MESSAGE_TYPE_CAPABILITY_REQUEST = 6;
|
||||
MESSAGE_TYPE_CAPABILITY_RESPONSE = 7;
|
||||
}
|
||||
|
||||
// RunRequest initiates a model execution.
|
||||
message RunRequest {
|
||||
string run_id = 1;
|
||||
string adapter = 2;
|
||||
string model = 3;
|
||||
string workspace = 4;
|
||||
bytes policy = 5; // JSON-encoded policy map
|
||||
bytes input = 6; // JSON-encoded input map
|
||||
int32 timeout_sec = 7;
|
||||
map<string, string> metadata = 8;
|
||||
}
|
||||
|
||||
// RunEvent is a streaming execution event.
|
||||
message RunEvent {
|
||||
string run_id = 1;
|
||||
string type = 2; // start | delta | complete | error
|
||||
string delta = 3;
|
||||
string message = 4;
|
||||
string error = 5;
|
||||
Usage usage = 6;
|
||||
map<string, string> metadata = 7;
|
||||
int64 timestamp = 8; // unix nano
|
||||
}
|
||||
|
||||
message Usage {
|
||||
int32 input_tokens = 1;
|
||||
int32 output_tokens = 2;
|
||||
}
|
||||
|
||||
// Heartbeat is sent by both sides to keep the connection alive.
|
||||
message Heartbeat {
|
||||
int64 timestamp = 1;
|
||||
}
|
||||
|
||||
// CancelRequest asks the node to cancel a running execution.
|
||||
message CancelRequest {
|
||||
string run_id = 1;
|
||||
}
|
||||
|
||||
// Error is returned when a request fails at the transport layer.
|
||||
message Error {
|
||||
string code = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
// CapabilityRequest asks the node what adapters/models it supports.
|
||||
message CapabilityRequest {}
|
||||
|
||||
// CapabilityResponse lists the node's available adapters.
|
||||
message CapabilityResponse {
|
||||
string node_id = 1;
|
||||
repeated AdapterInfo adapters = 2;
|
||||
}
|
||||
|
||||
message AdapterInfo {
|
||||
string name = 1;
|
||||
repeated string models = 2;
|
||||
int32 max_concurrency = 3;
|
||||
}
|
||||
Loading…
Reference in a new issue