From 2d6fde08766fc2128886c23405b00942daec922b Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 2 May 2026 13:20:35 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B8=B0=EB=8A=A5:=20IOP=20=EB=AA=A8=EB=85=B8?= =?UTF-8?q?=EB=A0=88=ED=8F=AC=20=EC=8A=A4=EC=BA=90=ED=8F=B4=EB=93=9C=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/node 중심 구현 — TCP+JSON transport, Hexagonal Architecture, mock/cli adapter, fx DI, SQLite 실행 이력 저장. edge/control-plane/worker는 cobra placeholder. 유닛 테스트 및 통합 테스트 클라이언트 계획서 추가. --- .clinerules | 16 + .cursorrules | 16 + .gitignore | 3 + AGENTS.md | 16 + CLAUDE.md | 16 + GEMINI.md | 16 + Makefile | 31 ++ README.md | 61 +++ agent-ops/.version | 1 + .../common/_templates/domain-rule-template.md | 29 ++ agent-ops/rules/common/rules.md | 16 + .../common/_templates/skill-template.md | 57 +++ agent-ops/skills/common/code-review/SKILL.md | 218 ++++++++ .../common/code-review/agents/openai.yaml | 4 + agent-ops/skills/common/commit-push/SKILL.md | 124 +++++ .../skills/common/create-domain-rule/SKILL.md | 104 ++++ agent-ops/skills/common/create-skill/SKILL.md | 95 ++++ .../skills/common/init-agent-ops/SKILL.md | 228 +++++++++ agent-ops/skills/common/plan/SKILL.md | 202 ++++++++ .../skills/common/plan/agents/openai.yaml | 4 + agent-ops/skills/common/router.md | 13 + agent-ops/skills/common/sync-pull/SKILL.md | 36 ++ agent-ops/skills/common/sync-push/SKILL.md | 43 ++ .../skills/common/update-domain-rule/SKILL.md | 100 ++++ agent-task/node_testclient/CODE_REVIEW.md | 75 +++ agent-task/node_testclient/PLAN.md | 345 +++++++++++++ agent-task/node_unit_tests/CODE_REVIEW.md | 82 +++ agent-task/node_unit_tests/PLAN.md | 481 ++++++++++++++++++ apps/control-plane/README.md | 12 + .../cmd/iop-control-plane/main.go | 34 ++ apps/edge/README.md | 15 + apps/edge/cmd/iop-edge/main.go | 34 ++ apps/node/README.md | 54 ++ apps/node/cmd/iop-node/main.go | 85 ++++ apps/node/internal/adapters/cli/cli.go | 144 ++++++ apps/node/internal/adapters/mock/mock.go | 95 ++++ apps/node/internal/adapters/ollama/ollama.go | 48 ++ apps/node/internal/adapters/registry.go | 56 ++ apps/node/internal/adapters/vllm/vllm.go | 49 ++ apps/node/internal/bootstrap/module.go | 101 ++++ apps/node/internal/node/node.go | 169 ++++++ apps/node/internal/router/router.go | 53 ++ apps/node/internal/runtime/types.go | 89 ++++ apps/node/internal/store/store.go | 100 ++++ apps/node/internal/transport/codec.go | 32 ++ apps/node/internal/transport/frame.go | 63 +++ apps/node/internal/transport/server.go | 135 +++++ apps/node/internal/transport/session.go | 228 +++++++++ apps/node/internal/transport/tls.go | 17 + apps/worker/README.md | 11 + apps/worker/cmd/iop-worker/main.go | 33 ++ configs/control-plane.yaml | 11 + configs/edge.yaml | 11 + configs/node.yaml | 57 +++ configs/worker.yaml | 11 + docs/architecture.md | 129 +++++ go.mod | 14 + packages/auth/auth.go | 58 +++ packages/config/config.go | 100 ++++ packages/jobs/jobs.go | 28 + packages/metadata/metadata.go | 30 ++ packages/observability/observability.go | 34 ++ packages/policy/engine.go | 25 + packages/protocol/protocol.go | 92 ++++ packages/version/version.go | 3 + proto/iop/control.proto | 27 + proto/iop/job.proto | 39 ++ proto/iop/node.proto | 34 ++ proto/iop/runtime.proto | 86 ++++ 69 files changed, 4878 insertions(+) create mode 100644 .clinerules create mode 100644 .cursorrules create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 GEMINI.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 agent-ops/.version create mode 100644 agent-ops/rules/common/_templates/domain-rule-template.md create mode 100644 agent-ops/rules/common/rules.md create mode 100644 agent-ops/skills/common/_templates/skill-template.md create mode 100644 agent-ops/skills/common/code-review/SKILL.md create mode 100644 agent-ops/skills/common/code-review/agents/openai.yaml create mode 100644 agent-ops/skills/common/commit-push/SKILL.md create mode 100644 agent-ops/skills/common/create-domain-rule/SKILL.md create mode 100644 agent-ops/skills/common/create-skill/SKILL.md create mode 100644 agent-ops/skills/common/init-agent-ops/SKILL.md create mode 100644 agent-ops/skills/common/plan/SKILL.md create mode 100644 agent-ops/skills/common/plan/agents/openai.yaml create mode 100644 agent-ops/skills/common/router.md create mode 100644 agent-ops/skills/common/sync-pull/SKILL.md create mode 100644 agent-ops/skills/common/sync-push/SKILL.md create mode 100644 agent-ops/skills/common/update-domain-rule/SKILL.md create mode 100644 agent-task/node_testclient/CODE_REVIEW.md create mode 100644 agent-task/node_testclient/PLAN.md create mode 100644 agent-task/node_unit_tests/CODE_REVIEW.md create mode 100644 agent-task/node_unit_tests/PLAN.md create mode 100644 apps/control-plane/README.md create mode 100644 apps/control-plane/cmd/iop-control-plane/main.go create mode 100644 apps/edge/README.md create mode 100644 apps/edge/cmd/iop-edge/main.go create mode 100644 apps/node/README.md create mode 100644 apps/node/cmd/iop-node/main.go create mode 100644 apps/node/internal/adapters/cli/cli.go create mode 100644 apps/node/internal/adapters/mock/mock.go create mode 100644 apps/node/internal/adapters/ollama/ollama.go create mode 100644 apps/node/internal/adapters/registry.go create mode 100644 apps/node/internal/adapters/vllm/vllm.go create mode 100644 apps/node/internal/bootstrap/module.go create mode 100644 apps/node/internal/node/node.go create mode 100644 apps/node/internal/router/router.go create mode 100644 apps/node/internal/runtime/types.go create mode 100644 apps/node/internal/store/store.go create mode 100644 apps/node/internal/transport/codec.go create mode 100644 apps/node/internal/transport/frame.go create mode 100644 apps/node/internal/transport/server.go create mode 100644 apps/node/internal/transport/session.go create mode 100644 apps/node/internal/transport/tls.go create mode 100644 apps/worker/README.md create mode 100644 apps/worker/cmd/iop-worker/main.go create mode 100644 configs/control-plane.yaml create mode 100644 configs/edge.yaml create mode 100644 configs/node.yaml create mode 100644 configs/worker.yaml create mode 100644 docs/architecture.md create mode 100644 go.mod create mode 100644 packages/auth/auth.go create mode 100644 packages/config/config.go create mode 100644 packages/jobs/jobs.go create mode 100644 packages/metadata/metadata.go create mode 100644 packages/observability/observability.go create mode 100644 packages/policy/engine.go create mode 100644 packages/protocol/protocol.go create mode 100644 packages/version/version.go create mode 100644 proto/iop/control.proto create mode 100644 proto/iop/job.proto create mode 100644 proto/iop/node.proto create mode 100644 proto/iop/runtime.proto 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; +}