commit 2d6fde08766fc2128886c23405b00942daec922b Author: toki Date: Sat May 2 13:20:35 2026 +0900 기능: IOP 모노레포 스캐폴드 초기 구현 apps/node 중심 구현 — TCP+JSON transport, Hexagonal Architecture, mock/cli adapter, fx DI, SQLite 실행 이력 저장. edge/control-plane/worker는 cobra placeholder. 유닛 테스트 및 통합 테스트 클라이언트 계획서 추가. diff --git a/.clinerules b/.clinerules new file mode 100644 index 0000000..6648b75 --- /dev/null +++ b/.clinerules @@ -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`를 읽고 작업을 시작한다. 파일이 없을경우 무시한다. diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..6648b75 --- /dev/null +++ b/.cursorrules @@ -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`를 읽고 작업을 시작한다. 파일이 없을경우 무시한다. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..033894c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ + +# Agent-Ops Private Rules +agent-ops/rules/private/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6648b75 --- /dev/null +++ b/AGENTS.md @@ -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`를 읽고 작업을 시작한다. 파일이 없을경우 무시한다. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6648b75 --- /dev/null +++ b/CLAUDE.md @@ -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`를 읽고 작업을 시작한다. 파일이 없을경우 무시한다. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..6648b75 --- /dev/null +++ b/GEMINI.md @@ -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`를 읽고 작업을 시작한다. 파일이 없을경우 무시한다. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..be4b285 --- /dev/null +++ b/Makefile @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8d76d7 --- /dev/null +++ b/README.md @@ -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/ # 문서 +``` diff --git a/agent-ops/.version b/agent-ops/.version new file mode 100644 index 0000000..2bf1ca5 --- /dev/null +++ b/agent-ops/.version @@ -0,0 +1 @@ +1.1.7 diff --git a/agent-ops/rules/common/_templates/domain-rule-template.md b/agent-ops/rules/common/_templates/domain-rule-template.md new file mode 100644 index 0000000..529e10d --- /dev/null +++ b/agent-ops/rules/common/_templates/domain-rule-template.md @@ -0,0 +1,29 @@ +# + +## 목적 / 책임 + +<이 도메인이 담당하는 책임을 1~2문장으로> + +## 포함 경로 + +- `/` — <이 경로가 이 도메인에 속하는 이유> + +## 제외 경로 + +- `/` — <왜 이 도메인이 아닌지> + +## 주요 구성 요소 + +- `` — <역할> + +## 유지할 패턴 + +- <네이밍 규칙 또는 아키텍처 패턴> + +## 다른 도메인과의 경계 + +- **<인접 domain>**: <어디까지가 이 도메인이고 어디서부터 저 도메인인지> + +## 금지 사항 + +- <이 도메인 코드에서 하면 안 되는 것> diff --git a/agent-ops/rules/common/rules.md b/agent-ops/rules/common/rules.md new file mode 100644 index 0000000..6648b75 --- /dev/null +++ b/agent-ops/rules/common/rules.md @@ -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`를 읽고 작업을 시작한다. 파일이 없을경우 무시한다. diff --git a/agent-ops/skills/common/_templates/skill-template.md b/agent-ops/skills/common/_templates/skill-template.md new file mode 100644 index 0000000..2372acb --- /dev/null +++ b/agent-ops/skills/common/_templates/skill-template.md @@ -0,0 +1,57 @@ +--- +name: +version: 1.0.0 +description: <이 skill이 하는 일을 한 줄로 설명. 트리거 키워드 포함 권장> +depends: [] # 선택 — 의존 skill이 없으면 이 줄 삭제 +--- + +# + +## 목적 + +<이 skill이 해결하는 문제를 1~2문장으로 설명> + +## 언제 호출할지 + +- <이 skill을 호출해야 하는 상황 1> +- <이 skill을 호출해야 하는 상황 2> +- <이 skill을 호출해야 하는 상황 3> + +## 입력 + +- ``: <설명> (필수) +- ``: <설명> (선택) + +## 먼저 확인할 것 + +- [ ] <실행 전 반드시 확인해야 할 조건 1> +- [ ] <실행 전 반드시 확인해야 할 조건 2> + +## 실행 절차 + +1. **<단계명>** + - <세부 행동> + - <세부 행동> + +2. **<단계명>** + - <세부 행동> + +3. **결과 보고** + - <출력할 내용> + +## 실행 결과 검증 + +- [ ] <실행 후 확인해야 할 성공 조건 1> +- [ ] <실행 후 확인해야 할 성공 조건 2> +- 검증 실패 시: <실패 시 취할 행동 — 롤백, 사용자 알림, 재시도 등> + +## 출력 형식 + +``` +<출력 예시> +``` + +## 금지 사항 + +- <절대 하면 안 되는 것> +- <절대 하면 안 되는 것> diff --git a/agent-ops/skills/common/code-review/SKILL.md b/agent-ops/skills/common/code-review/SKILL.md new file mode 100644 index 0000000..7ae784b --- /dev/null +++ b/agent-ops/skills/common/code-review/SKILL.md @@ -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_`. +- `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 + + +# 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`. diff --git a/agent-ops/skills/common/code-review/agents/openai.yaml b/agent-ops/skills/common/code-review/agents/openai.yaml new file mode 100644 index 0000000..d45d468 --- /dev/null +++ b/agent-ops/skills/common/code-review/agents/openai.yaml @@ -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." diff --git a/agent-ops/skills/common/commit-push/SKILL.md b/agent-ops/skills/common/commit-push/SKILL.md new file mode 100644 index 0000000..09e8ace --- /dev/null +++ b/agent-ops/skills/common/commit-push/SKILL.md @@ -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` 옵션을 사용하지 않는다 diff --git a/agent-ops/skills/common/create-domain-rule/SKILL.md b/agent-ops/skills/common/create-domain-rule/SKILL.md new file mode 100644 index 0000000..5e553c1 --- /dev/null +++ b/agent-ops/skills/common/create-domain-rule/SKILL.md @@ -0,0 +1,104 @@ +--- +name: create-domain-rule +version: 1.0.0 +description: 새로운 도메인 rules.md 파일을 생성하기 위한 범용 스킬 +--- + +# Create Domain Rule + +## 목적 + +`agent-ops/rules/project/domain//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//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 rule 이 있으면 사용자에게 알리고 중단한다 + +2. **경로 탐색** + - `path-hints` 가 제공된 경우: 해당 경로를 기준으로 폴더 구조 확인 + - `path-hints` 가 없는 경우: `domain-name` 과 연관된 폴더명·파일명으로 프로젝트를 탐색 + - 실제로 존재하는 경로만 포함 경로에 기재한다 + - 존재하지 않는 경로는 기재하지 않는다 + +3. **도메인 분석** + - 탐색된 경로의 주요 파일·모듈을 읽어 아래를 도출한다 + - 도메인의 목적 / 책임 한 줄 요약 + - 포함 경로 목록 + - 제외 경로 (인접 도메인과 겹칠 수 있는 경로) + - 주요 구성 요소 (파일, 모듈, 클래스, 함수 등) + - 유지할 패턴 (네이밍 규칙, 아키텍처 패턴 등) + - 다른 도메인과의 경계 + - 금지 사항 + +4. **도메인 rules.md 생성** + - 경로: `agent-ops/rules/project/domain//rules.md` + - `domain-rule-template.md` 형식을 따른다 + - 실제 코드에서 확인된 내용만 기재한다 + - 불확실한 항목은 `` 주석으로 남긴다 + +5. **rules/project/rules.md 업데이트** + - `agent-ops/rules/project/rules.md` 를 읽는다 + - 도메인 매핑 테이블에 아래 형식으로 추가한다 + ``` + | `` | `agent-ops/rules/project/domain//rules.md` | + ``` + - 경로 패턴은 실제 포함 경로를 기준으로 작성한다 + - 기존 테이블 항목을 삭제하거나 재정렬하지 않는다 + +6. **결과 보고** + - 생성한 파일 경로 + - rules/project/rules.md 에 추가한 항목 + - 불확실하여 TODO로 남긴 항목 (해당 시) + +## 출력 형식 + +``` +## 생성 완료 + +- Domain Rule 경로: agent-ops/rules/project/domain//rules.md +- 도메인 유형: +- rules/project/rules.md 추가 경로 패턴: + +## TODO 항목 (확인 필요) +- <불확실하여 직접 확인이 필요한 항목> (해당 시) +``` + +## 실행 결과 검증 + +- [ ] `agent-ops/rules/project/domain//rules.md` 파일이 생성되었는가 +- [ ] 생성된 파일이 `domain-rule-template.md`의 모든 섹션(목적, 포함 경로, 제외 경로, 주요 구성 요소, 유지할 패턴, 경계, 금지 사항)을 포함하는가 +- [ ] `rules/project/rules.md`의 도메인 매핑 테이블에 해당 도메인 항목이 추가되었는가 +- [ ] 포함 경로에 기재된 경로가 실제로 프로젝트에 존재하는가 +- 검증 실패 시: 누락된 섹션 또는 잘못된 경로를 사용자에게 알리고 해당 부분만 보완한다 + +## 금지 사항 + +- 이미 존재하는 rules.md 를 덮어쓰지 않는다 +- 실제 존재하지 않는 경로를 포함 경로에 넣지 않는다 +- 추측으로 패턴이나 금지 사항을 채우지 않는다 — 확인된 내용만 기재한다 +- rules/project/rules.md 의 기존 항목을 삭제하거나 재정렬하지 않는다 +- 하나의 domain rule 에 여러 독립 도메인의 책임을 묶지 않는다 +- 코드 변경을 수행하지 않는다 — 이 skill 은 rule 파일 생성만 담당한다 diff --git a/agent-ops/skills/common/create-skill/SKILL.md b/agent-ops/skills/common/create-skill/SKILL.md new file mode 100644 index 0000000..07bb36c --- /dev/null +++ b/agent-ops/skills/common/create-skill/SKILL.md @@ -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.md` +- `.agent-ops-source` 파일이 **없으면** (타겟 프로젝트): `agent-ops/skills/project//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//` 및 `agent-ops/skills/project//` 폴더 존재 여부 확인 + - 기능이 겹치는 기존 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.md +- 라우팅 추가: <대상 파일> → <라우팅 축> → + +## 주의사항 (해당 시) +- <이 skill이 다루지 않는 범위 또는 주의할 점> +``` + +## 실행 결과 검증 + +- [ ] `agent-ops/skills/{common|project}//SKILL.md` 파일이 생성되었는가 +- [ ] 생성된 파일이 `skill-template.md`의 필수 섹션(목적, 언제 호출할지, 실행 절차, 실행 결과 검증, 출력 형식, 금지 사항)을 포함하는가 +- [ ] frontmatter에 name, version, description이 올바르게 기재되었는가 +- [ ] 라우팅 대상 파일에 해당 스킬의 라우팅 항목이 추가되었는가 +- 검증 실패 시: 누락된 섹션 또는 라우팅 항목을 사용자에게 알리고 해당 부분만 보완한다 + +## 금지 사항 + +- 이미 존재하는 skill 을 덮어쓰지 않는다 +- 프로젝트 특화 경로(예: `app/screens/`)를 skill 본문에 하드코딩하지 않는다 +- skill 생성과 무관한 코드 파일을 수정하지 않는다 +- 라우팅 대상 파일의 기존 항목을 삭제하거나 재정렬하지 않는다 +- 하나의 skill 에 여러 독립적인 책임을 묶지 않는다 diff --git a/agent-ops/skills/common/init-agent-ops/SKILL.md b/agent-ops/skills/common/init-agent-ops/SKILL.md new file mode 100644 index 0000000..507c29c --- /dev/null +++ b/agent-ops/skills/common/init-agent-ops/SKILL.md @@ -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//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 +``` + +## 실행 절차 + +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와 중복되는 내용을 넣지 않는다. diff --git a/agent-ops/skills/common/plan/SKILL.md b/agent-ops/skills/common/plan/SKILL.md new file mode 100644 index 0000000..fefbf5f --- /dev/null +++ b/agent-ops/skills/common/plan/SKILL.md @@ -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 + +``` + +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 + + +# 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_` | Follow-up fixes after review | + +## Final Checklist + +- `PLAN.md` and `CODE_REVIEW.md` both exist under `agent-task/{task_name}/`. +- Both first lines match ``. +- 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. diff --git a/agent-ops/skills/common/plan/agents/openai.yaml b/agent-ops/skills/common/plan/agents/openai.yaml new file mode 100644 index 0000000..751c35e --- /dev/null +++ b/agent-ops/skills/common/plan/agents/openai.yaml @@ -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." diff --git a/agent-ops/skills/common/router.md b/agent-ops/skills/common/router.md new file mode 100644 index 0000000..49ce8d2 --- /dev/null +++ b/agent-ops/skills/common/router.md @@ -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` | diff --git a/agent-ops/skills/common/sync-pull/SKILL.md b/agent-ops/skills/common/sync-pull/SKILL.md new file mode 100644 index 0000000..9afcdb1 --- /dev/null +++ b/agent-ops/skills/common/sync-pull/SKILL.md @@ -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 +``` + +## 실행 결과 검증 + +- [ ] sync.sh 가 오류 없이 완료됐는가 +- [ ] 현재 프로젝트 버전이 framework 버전과 일치하는가 +- [ ] 진입 파일(CLAUDE.md 등)이 갱신됐는가 + +## 금지 사항 + +- sync.sh 를 거치지 않고 직접 파일을 복사하지 않는다 diff --git a/agent-ops/skills/common/sync-push/SKILL.md b/agent-ops/skills/common/sync-push/SKILL.md new file mode 100644 index 0000000..7f18074 --- /dev/null +++ b/agent-ops/skills/common/sync-push/SKILL.md @@ -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 ` 실행 +3. **명시되지 않은 경우**: 사용자에게 대상 프로젝트 입력을 요청한다 + +```bash +agent-ops/bin/sync.sh +``` + +## 실행 결과 검증 + +- [ ] sync.sh 가 오류 없이 완료됐는가 +- [ ] 버전 충돌 경고가 없었는가 — 있었다면 사용자에게 수동 머지 필요함을 알린다 + +## 금지 사항 + +- sync.sh 를 거치지 않고 직접 파일을 복사하지 않는다 diff --git a/agent-ops/skills/common/update-domain-rule/SKILL.md b/agent-ops/skills/common/update-domain-rule/SKILL.md new file mode 100644 index 0000000..b5beb3e --- /dev/null +++ b/agent-ops/skills/common/update-domain-rule/SKILL.md @@ -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//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 업데이트** + - 변경이 필요한 항목만 수정한다 — 변경 불필요한 섹션은 그대로 둔다 + - 확인된 사실만 기재한다; 불확실하면 `` 주석 처리 + - `domain-rule-template.md`의 섹션 구조를 유지한다 + +5. **도메인 매핑 테이블 검토** (`full` 모드 시) + - `agent-ops/rules/project/rules.md`의 도메인 매핑 테이블과 실제 포함 경로 비교 + - 누락된 경로 패턴은 추가, 존재하지 않는 경로 패턴은 제거 + - 기존 항목 순서는 변경하지 않는다 + +6. **결과 보고** + - 도메인별로 수정한 항목 목록 + - `TODO`로 남긴 항목 (해당 시) + - 도메인 매핑 테이블 변경 내용 (full 모드 시) + +## 실행 결과 검증 + +- [ ] 수정된 `rules.md`의 포함 경로가 모두 실제 프로젝트에 존재하는가 +- [ ] 섹션 구조가 `domain-rule-template.md` 형식을 유지하는가 +- [ ] 변경하지 않아도 되는 섹션이 의도치 않게 바뀌지 않았는가 +- [ ] `full` 모드에서 도메인 매핑 테이블이 실제 포함 경로와 일치하는가 +- 검증 실패 시: 실제 존재하지 않는 경로나 누락된 섹션을 사용자에게 알리고 해당 항목만 보완한다 + +## 출력 형식 + +``` +## 업데이트 완료 + +### +- 변경: <수정된 항목 요약> +- 추가: <새로 추가된 경로/구성 요소> +- 제거: <삭제된 경로/구성 요소> +- 유지: 변경 없음 (해당 섹션) + +### 도메인 매핑 테이블 (full 모드 시) +- 추가: <새 경로 패턴> +- 제거: <삭제된 경로 패턴> + +## TODO 항목 (확인 필요) +- <불확실하여 직접 확인이 필요한 항목> (해당 시) +``` + +## 금지 사항 + +- 실제 존재하지 않는 경로를 포함 경로에 기재하지 않는다 +- 추측으로 패턴·금지 사항을 추가하지 않는다 — 코드에서 확인된 내용만 기재한다 +- 신규 도메인 생성이 필요한 경우 직접 생성하지 않고 `create-domain-rule` 스킬 사용을 안내한다 +- `rules/project/rules.md`의 기존 항목을 삭제하거나 재정렬하지 않는다 +- 코드 파일을 수정하지 않는다 — 이 스킬은 rule 파일 갱신만 담당한다 diff --git a/agent-task/node_testclient/CODE_REVIEW.md b/agent-task/node_testclient/CODE_REVIEW.md new file mode 100644 index 0000000..a40cbac --- /dev/null +++ b/agent-task/node_testclient/CODE_REVIEW.md @@ -0,0 +1,75 @@ + + +# 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) +``` diff --git a/agent-task/node_testclient/PLAN.md b/agent-task/node_testclient/PLAN.md new file mode 100644 index 0000000..a136772 --- /dev/null +++ b/agent-task/node_testclient/PLAN.md @@ -0,0 +1,345 @@ + + +# 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 ./... +``` diff --git a/agent-task/node_unit_tests/CODE_REVIEW.md b/agent-task/node_unit_tests/CODE_REVIEW.md new file mode 100644 index 0000000..0cb254e --- /dev/null +++ b/agent-task/node_unit_tests/CODE_REVIEW.md @@ -0,0 +1,82 @@ + + +# 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) +``` diff --git a/agent-task/node_unit_tests/PLAN.md b/agent-task/node_unit_tests/PLAN.md new file mode 100644 index 0000000..e052358 --- /dev/null +++ b/agent-task/node_unit_tests/PLAN.md @@ -0,0 +1,481 @@ + + +# 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, 빌드 에러 없음 diff --git a/apps/control-plane/README.md b/apps/control-plane/README.md new file mode 100644 index 0000000..b0d6580 --- /dev/null +++ b/apps/control-plane/README.md @@ -0,0 +1,12 @@ +# iop-control-plane — Control Plane + +노드 등록, 정책 적용, 작업 스케줄링을 담당한다. + +**현재 상태: Placeholder** + +## 계획된 기능 + +- 노드 등록 및 헬스체크 +- 정책 DSL 평가 +- 작업 스케줄링 (라운드로빈 / 최소 부하) +- 노드 드레이닝 / 업그레이드 조율 diff --git a/apps/control-plane/cmd/iop-control-plane/main.go b/apps/control-plane/cmd/iop-control-plane/main.go new file mode 100644 index 0000000..70d1738 --- /dev/null +++ b/apps/control-plane/cmd/iop-control-plane/main.go @@ -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 +} diff --git a/apps/edge/README.md b/apps/edge/README.md new file mode 100644 index 0000000..a100da6 --- /dev/null +++ b/apps/edge/README.md @@ -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 프록시 diff --git a/apps/edge/cmd/iop-edge/main.go b/apps/edge/cmd/iop-edge/main.go new file mode 100644 index 0000000..3d09e48 --- /dev/null +++ b/apps/edge/cmd/iop-edge/main.go @@ -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 +} diff --git a/apps/node/README.md b/apps/node/README.md new file mode 100644 index 0000000..b7dfc5b --- /dev/null +++ b/apps/node/README.md @@ -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초 간격 diff --git a/apps/node/cmd/iop-node/main.go b/apps/node/cmd/iop-node/main.go new file mode 100644 index 0000000..ae06bc8 --- /dev/null +++ b/apps/node/cmd/iop-node/main.go @@ -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 +} diff --git a/apps/node/internal/adapters/cli/cli.go b/apps/node/internal/adapters/cli/cli.go new file mode 100644 index 0000000..91dbff6 --- /dev/null +++ b/apps/node/internal/adapters/cli/cli.go @@ -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) +} diff --git a/apps/node/internal/adapters/mock/mock.go b/apps/node/internal/adapters/mock/mock.go new file mode 100644 index 0000000..3a558e4 --- /dev/null +++ b/apps/node/internal/adapters/mock/mock.go @@ -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) +} diff --git a/apps/node/internal/adapters/ollama/ollama.go b/apps/node/internal/adapters/ollama/ollama.go new file mode 100644 index 0000000..cc066e9 --- /dev/null +++ b/apps/node/internal/adapters/ollama/ollama.go @@ -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") +} diff --git a/apps/node/internal/adapters/registry.go b/apps/node/internal/adapters/registry.go new file mode 100644 index 0000000..48670a7 --- /dev/null +++ b/apps/node/internal/adapters/registry.go @@ -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 +} diff --git a/apps/node/internal/adapters/vllm/vllm.go b/apps/node/internal/adapters/vllm/vllm.go new file mode 100644 index 0000000..0b1497a --- /dev/null +++ b/apps/node/internal/adapters/vllm/vllm.go @@ -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") +} diff --git a/apps/node/internal/bootstrap/module.go b/apps/node/internal/bootstrap/module.go new file mode 100644 index 0000000..16c7d6d --- /dev/null +++ b/apps/node/internal/bootstrap/module.go @@ -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() + }, + }) + }), + ) +} diff --git a/apps/node/internal/node/node.go b/apps/node/internal/node/node.go new file mode 100644 index 0000000..8684ff1 --- /dev/null +++ b/apps/node/internal/node/node.go @@ -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, + }) +} diff --git a/apps/node/internal/router/router.go b/apps/node/internal/router/router.go new file mode 100644 index 0000000..bb76cbe --- /dev/null +++ b/apps/node/internal/router/router.go @@ -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 +} diff --git a/apps/node/internal/runtime/types.go b/apps/node/internal/runtime/types.go new file mode 100644 index 0000000..51880b7 --- /dev/null +++ b/apps/node/internal/runtime/types.go @@ -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 +} diff --git a/apps/node/internal/store/store.go b/apps/node/internal/store/store.go new file mode 100644 index 0000000..484c757 --- /dev/null +++ b/apps/node/internal/store/store.go @@ -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 +} diff --git a/apps/node/internal/transport/codec.go b/apps/node/internal/transport/codec.go new file mode 100644 index 0000000..8376e77 --- /dev/null +++ b/apps/node/internal/transport/codec.go @@ -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) +} diff --git a/apps/node/internal/transport/frame.go b/apps/node/internal/transport/frame.go new file mode 100644 index 0000000..36d0f2b --- /dev/null +++ b/apps/node/internal/transport/frame.go @@ -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 +} diff --git a/apps/node/internal/transport/server.go b/apps/node/internal/transport/server.go new file mode 100644 index 0000000..a883677 --- /dev/null +++ b/apps/node/internal/transport/server.go @@ -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)) +} diff --git a/apps/node/internal/transport/session.go b/apps/node/internal/transport/session.go new file mode 100644 index 0000000..3a3837b --- /dev/null +++ b/apps/node/internal/transport/session.go @@ -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 + } + } + } +} diff --git a/apps/node/internal/transport/tls.go b/apps/node/internal/transport/tls.go new file mode 100644 index 0000000..4d2eb32 --- /dev/null +++ b/apps/node/internal/transport/tls.go @@ -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) +} diff --git a/apps/worker/README.md b/apps/worker/README.md new file mode 100644 index 0000000..d873a2f --- /dev/null +++ b/apps/worker/README.md @@ -0,0 +1,11 @@ +# iop-worker — Async Worker + +비동기 작업 큐에서 작업을 꺼내어 처리한다. + +**현재 상태: Placeholder** + +## 계획된 기능 + +- 작업 큐 소비 (SQLite / Redis) +- 재시도 정책 +- 결과 저장 및 콜백 diff --git a/apps/worker/cmd/iop-worker/main.go b/apps/worker/cmd/iop-worker/main.go new file mode 100644 index 0000000..1f6ee60 --- /dev/null +++ b/apps/worker/cmd/iop-worker/main.go @@ -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 +} diff --git a/configs/control-plane.yaml b/configs/control-plane.yaml new file mode 100644 index 0000000..f2c7863 --- /dev/null +++ b/configs/control-plane.yaml @@ -0,0 +1,11 @@ +server: + listen: "0.0.0.0:9080" + +tls: + enabled: false + +logging: + level: "info" + +metrics: + port: 9093 diff --git a/configs/edge.yaml b/configs/edge.yaml new file mode 100644 index 0000000..caad597 --- /dev/null +++ b/configs/edge.yaml @@ -0,0 +1,11 @@ +server: + listen: "0.0.0.0:8080" + +tls: + enabled: false + +logging: + level: "info" + +metrics: + port: 9092 diff --git a/configs/node.yaml b/configs/node.yaml new file mode 100644 index 0000000..3429def --- /dev/null +++ b/configs/node.yaml @@ -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: [] diff --git a/configs/worker.yaml b/configs/worker.yaml new file mode 100644 index 0000000..479c26f --- /dev/null +++ b/configs/worker.yaml @@ -0,0 +1,11 @@ +worker: + concurrency: 8 + +sqlite: + dsn: "file:iop-worker.db?cache=shared&mode=rwc" + +logging: + level: "info" + +metrics: + port: 9094 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9b7ca49 --- /dev/null +++ b/docs/architecture.md @@ -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//` 패키지 생성 +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 의존성 금지 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0a8ce9f --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/packages/auth/auth.go b/packages/auth/auth.go new file mode 100644 index 0000000..4398e51 --- /dev/null +++ b/packages/auth/auth.go @@ -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 +} diff --git a/packages/config/config.go b/packages/config/config.go new file mode 100644 index 0000000..59b27fd --- /dev/null +++ b/packages/config/config.go @@ -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) +} diff --git a/packages/jobs/jobs.go b/packages/jobs/jobs.go new file mode 100644 index 0000000..2313275 --- /dev/null +++ b/packages/jobs/jobs.go @@ -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"` +} diff --git a/packages/metadata/metadata.go b/packages/metadata/metadata.go new file mode 100644 index 0000000..c5517d4 --- /dev/null +++ b/packages/metadata/metadata.go @@ -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 +} diff --git a/packages/observability/observability.go b/packages/observability/observability.go new file mode 100644 index 0000000..e35be72 --- /dev/null +++ b/packages/observability/observability.go @@ -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) +} diff --git a/packages/policy/engine.go b/packages/policy/engine.go new file mode 100644 index 0000000..1c08c3a --- /dev/null +++ b/packages/policy/engine.go @@ -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 +} diff --git a/packages/protocol/protocol.go b/packages/protocol/protocol.go new file mode 100644 index 0000000..5573372 --- /dev/null +++ b/packages/protocol/protocol.go @@ -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"` +} diff --git a/packages/version/version.go b/packages/version/version.go new file mode 100644 index 0000000..31646b0 --- /dev/null +++ b/packages/version/version.go @@ -0,0 +1,3 @@ +package version + +const Version = "0.1.0" diff --git a/proto/iop/control.proto b/proto/iop/control.proto new file mode 100644 index 0000000..22800fa --- /dev/null +++ b/proto/iop/control.proto @@ -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 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 metadata = 4; +} + +// ScheduleResponse contains the selected node address. +message ScheduleResponse { + string node_id = 1; + string address = 2; + string token = 3; +} diff --git a/proto/iop/job.proto b/proto/iop/job.proto new file mode 100644 index 0000000..794ff4c --- /dev/null +++ b/proto/iop/job.proto @@ -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 metadata = 11; +} + +message JobListRequest { + JobStatus status_filter = 1; + int32 limit = 2; + string cursor = 3; +} + +message JobListResponse { + repeated Job jobs = 1; + string next = 2; +} diff --git a/proto/iop/node.proto b/proto/iop/node.proto new file mode 100644 index 0000000..0ef7fbd --- /dev/null +++ b/proto/iop/node.proto @@ -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 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; +} diff --git a/proto/iop/runtime.proto b/proto/iop/runtime.proto new file mode 100644 index 0000000..d1c6fd4 --- /dev/null +++ b/proto/iop/runtime.proto @@ -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 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 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; +}