feat(v0.1.3): LLM 어댑터 + in-process 모드 + docker-compose — 외부인 배포 친화
## LLM 공급자 어댑터 (B)
- sister-agent/src/llm/ 에 LlmAdapter 인터페이스 신설. 어댑터 5종:
openclaw (기존), openai, anthropic, ollama, mock
- 선택은 LLM_PROVIDER 환경변수로. 기본값 mock.
- 모델명은 LLM_MODEL_{MANAGER,PRINCIPAL,LEAD,JUNIOR} 로 외부화.
OpenClaw 내부 네이밍이 기본값이지만 env 로 얼마든지 갈아끼움.
- openai 어댑터는 OPENAI_BASE_URL 로 OpenRouter / Azure / 로컬 llama.cpp
서버까지 커버.
## In-process 단일 프로세스 모드 (C)
- sister-agent/src/core.ts 로 executeInvocation 을 library-export
- rails 에 InProcessTransport 추가. 동적 import 로 sister-agent core 를
로드해 같은 Node 프로세스에서 함수 호출로 실행.
- DirectRailsClient 로 HTTP 루프백 없이 DB 에 직접 쓰기 — 단일
프로세스에서도 observability 동일.
- RailsConfig 에 transport: "in-process" 추가.
- buildTransports 가 RAILS_TRANSPORT 와 per-stage override 를 지원하도록
확장. 레거시 RAILS_TRANSPORT_MODE + RAILS_AGENT_*_HOST 도 그대로 호환.
## Git push 외부화 + allowlist env 화
- spawn.ts 의 ENABLE_GIT_PUSH 를 "GITEA_TOKEN 있으면 auto on" 으로 변경.
기존 Dev 토폴로지는 sister LXC 들에 이미 토큰이 있어서 행동 변화 없음.
- rails.service.ts 의 파일 프록시 allowlist 를 GIT_RAW_ALLOWED_HOSTS
env 로 외부화. 기본값은 기존 Gitea 호스트 유지.
## Docker / 배포
- Dockerfile 추가. 단일 이미지로 rails + sister-agent 둘 다 빌드.
- docker-compose.yml (기본): mariadb + rails 한 컨테이너 = in-process.
docker compose up 한 줄로 로컬 E2E 가능.
- docker-compose.full.yml: rails + 4 개 독립 sister 컨테이너 = 분산.
## 설정 샘플 + 문서
- .env.example 완전 재작성: 필수/LLM/토폴로지/Gitea/Discord 5 섹션
- rails.config.local.yaml: in-process 샘플
- rails.config.distributed.yaml: http 분산 샘플
- docs/LOCAL-SETUP.md: 30분 퀵스타트 (Docker + 네이티브 두 경로)
- README 에 "5분 퀵스타트" + 토폴로지 표 + LLM 공급자 목록 추가
## 테스트
- tests/transport-build.test.ts (6 테스트): 기본값 / in-process /
per-stage override / http 엔드포인트 누락 / env 기반 wiring / 레거시
env 호환
- 전체 테스트 105 → 111 통과
This commit is contained in:
127
.env.example
127
.env.example
@@ -1,17 +1,122 @@
|
||||
# hanarang-rails environment variables
|
||||
# Copy to .env and fill in values.
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# hanarang-rails — environment configuration
|
||||
# Copy this file to `.env` and fill in the values you need.
|
||||
#
|
||||
# The file is grouped into:
|
||||
# 1. required (must set to run any pipeline)
|
||||
# 2. LLM provider (pick one)
|
||||
# 3. transport / deployment topology
|
||||
# 4. optional — Gitea push
|
||||
# 5. optional — Discord bridge
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Database (MariaDB / MySQL) ──
|
||||
# ==== 1. REQUIRED =====================================================
|
||||
|
||||
# MariaDB / MySQL connection string used by Prisma.
|
||||
# For docker-compose, use:
|
||||
# mysql://rails:rails@mariadb:3306/hanarang_rails
|
||||
DATABASE_URL="mysql://rails:CHANGE_ME@localhost:3306/hanarang_rails"
|
||||
|
||||
# ── Discord ──
|
||||
DISCORD_TOKEN=""
|
||||
DISCORD_GUILD_ID=""
|
||||
|
||||
# ── Gitea Webhook ──
|
||||
GITEA_WEBHOOK_SECRET=""
|
||||
|
||||
# ── Rails ──
|
||||
# Rails HTTP server port
|
||||
RAILS_PORT=18800
|
||||
RAILS_LOG_LEVEL=info
|
||||
NODE_ENV=production
|
||||
|
||||
|
||||
# ==== 2. LLM PROVIDER =================================================
|
||||
#
|
||||
# Pick exactly one provider for LLM_PROVIDER. Supported values:
|
||||
# mock — deterministic fake responses. No network, no money.
|
||||
# openai — OpenAI / OpenRouter / Azure OpenAI / any OpenAI-compatible API
|
||||
# anthropic — Anthropic Messages API
|
||||
# ollama — local Ollama server (https://ollama.com)
|
||||
# openclaw — hanarang-internal OpenClaw runtime (most external users won't have this)
|
||||
|
||||
LLM_PROVIDER=mock
|
||||
|
||||
# Per-role model override. Leave empty to use the defaults baked into roles.ts
|
||||
# (which are OpenClaw-flavored names — you probably need to set these for
|
||||
# openai / anthropic / ollama).
|
||||
#
|
||||
# Good starting points:
|
||||
# OpenAI: gpt-4o / gpt-4o-mini
|
||||
# Anthropic: claude-opus-4-6 / claude-haiku-4-5
|
||||
# Ollama: qwen2.5-coder:32b / qwen2.5-coder:7b
|
||||
#
|
||||
# LLM_MODEL_MANAGER=gpt-4o
|
||||
# LLM_MODEL_PRINCIPAL=gpt-4o
|
||||
# LLM_MODEL_LEAD=gpt-4o-mini
|
||||
# LLM_MODEL_JUNIOR=gpt-4o-mini
|
||||
# LLM_MODEL_FALLBACK=gpt-4o-mini
|
||||
|
||||
# ── OpenAI (and OpenAI-compatible) ────────────────────────────────────
|
||||
# OPENAI_API_KEY=sk-...
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
# (also works for OpenRouter, Azure OpenAI, local llama.cpp servers, etc.)
|
||||
|
||||
# ── Anthropic ─────────────────────────────────────────────────────────
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
# ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||
|
||||
# ── Ollama (local) ────────────────────────────────────────────────────
|
||||
# OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# ── OpenClaw (internal) ───────────────────────────────────────────────
|
||||
# OPENCLAW_BIN=/home/you/.npm-global/bin/openclaw
|
||||
|
||||
|
||||
# ==== 3. TRANSPORT / TOPOLOGY =========================================
|
||||
#
|
||||
# rails supports three deployment topologies:
|
||||
#
|
||||
# 1. in-process — everything in one Node process. The simplest. The 4
|
||||
# sister agents are just function calls inside rails.
|
||||
# Requires sister-agent to be built under
|
||||
# ./sister-agent/dist/.
|
||||
#
|
||||
# 2. http — rails calls each sister-agent over HTTP. The sister
|
||||
# agents run as separate daemons (potentially on separate
|
||||
# machines/containers). Production topology.
|
||||
#
|
||||
# 3. mock — no LLM, no files, no push. Just exercises the FSM.
|
||||
#
|
||||
# Set via RAILS_TRANSPORT globally, or per-stage via RAILS_TRANSPORT_PLAN etc.
|
||||
|
||||
RAILS_TRANSPORT=in-process
|
||||
|
||||
# For http mode — each sister-agent daemon's HTTP endpoint:
|
||||
# SISTER_ENDPOINT_PLAN=http://harang-lxc:18801
|
||||
# SISTER_ENDPOINT_IMPLEMENT=http://narang-lxc:18801
|
||||
# SISTER_ENDPOINT_REVIEW=http://darang-lxc:18801
|
||||
# SISTER_ENDPOINT_DEPLOY=http://erang-lxc:18801
|
||||
|
||||
# Loopback URL that sister-agent uses to report sub-task events back to rails.
|
||||
# Usually the same as your rails HTTP URL as seen from the sister.
|
||||
RAILS_API_URL=http://127.0.0.1:18800
|
||||
|
||||
# For in-process mode, optional override of where to load the compiled
|
||||
# sister-agent core module from. Defaults to ./sister-agent/dist/core.js
|
||||
# SISTER_AGENT_CORE_PATH=/app/sister-agent/dist/core.js
|
||||
|
||||
# Where sister-agent writes per-pipeline workspaces on disk.
|
||||
# SISTER_WORKSPACE_DIR=/home/you/rails-projects
|
||||
|
||||
|
||||
# ==== 4. OPTIONAL — Gitea auto-push ===================================
|
||||
#
|
||||
# When enabled, each pipeline run auto-creates a public repo and pushes its
|
||||
# workspace to Gitea, giving you a shareable URL for the generated files.
|
||||
# Leave GITEA_TOKEN empty to skip the push step entirely.
|
||||
|
||||
# GITEA_BASE_URL=https://git.example.com
|
||||
# GITEA_ORG=my-org
|
||||
# GITEA_TOKEN=
|
||||
# GIT_USER_NAME=rails-agent
|
||||
# GIT_USER_EMAIL=rails@example.com
|
||||
# GIT_PUSH_ENABLED=true # force on/off; default is auto (on iff GITEA_TOKEN set)
|
||||
|
||||
|
||||
# ==== 5. OPTIONAL — Discord bridge ====================================
|
||||
# DISCORD_TOKEN=
|
||||
# DISCORD_GUILD_ID=
|
||||
# GITEA_WEBHOOK_SECRET=
|
||||
|
||||
68
Dockerfile
Normal file
68
Dockerfile
Normal file
@@ -0,0 +1,68 @@
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Dockerfile — builds rails + sister-agent + CLI into one image
|
||||
#
|
||||
# The image starts `rails serve` in single-process (in-process) mode.
|
||||
# For distributed mode, see docker-compose.full.yml which uses the same
|
||||
# image but overrides CMD / env vars per container.
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
FROM node:22-bookworm-slim AS builder
|
||||
|
||||
RUN corepack enable \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifests first for better layer caching
|
||||
COPY package.json pnpm-lock.yaml tsconfig.json ./
|
||||
COPY prisma ./prisma
|
||||
COPY sister-agent/package.json ./sister-agent/
|
||||
COPY sister-agent/tsconfig.json ./sister-agent/
|
||||
|
||||
# Install deps (root + sister-agent workspace — sister-agent has its own lockfile)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
WORKDIR /app/sister-agent
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy sources
|
||||
COPY src ./src
|
||||
COPY sister-agent/src ./sister-agent/src
|
||||
|
||||
# Generate prisma client + build both
|
||||
RUN npx prisma generate \
|
||||
&& pnpm build \
|
||||
&& cd sister-agent && pnpm build
|
||||
|
||||
# ────────────── runtime image ──────────────
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
RUN corepack enable \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates openssl wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/sister-agent/package.json ./sister-agent/
|
||||
COPY --from=builder /app/sister-agent/node_modules ./sister-agent/node_modules
|
||||
COPY --from=builder /app/sister-agent/dist ./sister-agent/dist
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV RAILS_PORT=18800
|
||||
ENV RAILS_TRANSPORT=in-process
|
||||
ENV SISTER_AGENT_CORE_PATH=/app/sister-agent/dist/core.js
|
||||
ENV SISTER_WORKSPACE_DIR=/app/rails-projects
|
||||
|
||||
EXPOSE 18800
|
||||
|
||||
# Default command: run migrations then start the HTTP server
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/cli/index.js serve"]
|
||||
56
README.md
56
README.md
@@ -93,43 +93,46 @@
|
||||
- **v0.1.0** — Sprint 000~007 완료. FSM / Contract / QA / Migration 코어. 105 테스트 통과.
|
||||
- **v0.1.1** — 실 LLM 통합 (OpenClaw infer), 4 계층 재귀 스폰, 파일 추출, Gitea auto-push, deploy URL.
|
||||
- **v0.1.2** — 대시보드 아티팩트 뷰, FileViewerModal (MD 파일 클릭 → 모달).
|
||||
- **v0.1.3** — LLM 제공자 어댑터 (OpenAI / Anthropic / Ollama / OpenClaw / mock), in-process 단일 프로세스 모드, docker-compose, Gitea 호스트 완전 외부화, 외부 배포 친화 .env.example.
|
||||
|
||||
## 빠른 시작
|
||||
## 빠른 시작 (Docker, 5 분)
|
||||
|
||||
가장 짧은 경로. 로컬에 `docker` 와 `docker compose` 만 있으면 된다.
|
||||
|
||||
```bash
|
||||
# 1. 클론 + 설치
|
||||
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-rails.git
|
||||
cd hanarang-rails
|
||||
pnpm install
|
||||
|
||||
# 2. 환경 설정
|
||||
cp .env.example .env
|
||||
# DATABASE_URL, GITEA_TOKEN 등 채우기
|
||||
# .env 에서 LLM_PROVIDER=mock 으로 시작 (또는 openai/anthropic/ollama)
|
||||
|
||||
# 3. DB 마이그레이션
|
||||
pnpm prisma migrate deploy
|
||||
pnpm prisma generate
|
||||
docker compose up --build
|
||||
# → http://localhost:18800/health 확인
|
||||
|
||||
# 4. 빌드
|
||||
pnpm build
|
||||
|
||||
# 5. Mock 모드로 스모크 테스트
|
||||
pnpm rails run hello-world --mock -r "Try a pipeline"
|
||||
pnpm rails status
|
||||
|
||||
# 6. 서버 기동 (18800 포트)
|
||||
pnpm rails serve
|
||||
# 다른 터미널
|
||||
curl -X POST http://localhost:18800/pipelines/start \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"project":"hello","requirements":"Say hi"}'
|
||||
```
|
||||
|
||||
Sister-agent 는 각 LXC 에서 별도 기동:
|
||||
이게 끝. MariaDB + rails + sister-agent 4 개가 한 컨테이너 안에서 **in-process 모드** 로 돈다. 자세한 설정 옵션 (실제 LLM 키 연결, 네이티브 설치, 분산 토폴로지, 대시보드) 은 [`docs/LOCAL-SETUP.md`](docs/LOCAL-SETUP.md) 참조.
|
||||
|
||||
```bash
|
||||
cd sister-agent
|
||||
pnpm install && pnpm build
|
||||
AGENT_NAME=harang RAILS_API_URL=http://dev-vm:18800 node dist/server.js
|
||||
```
|
||||
### 배포 토폴로지
|
||||
|
||||
대시보드는 별도 repo `hanarang-dashboard` 참조.
|
||||
| 모드 | 설명 | 파일 |
|
||||
|---|---|---|
|
||||
| **in-process** | 모든 것을 하나의 Node 프로세스에서. 로컬 개발 기본값 | `docker-compose.yml` · `rails.config.local.yaml` |
|
||||
| **http 분산** | rails + 4 개 독립 sister-agent 컨테이너. 운영 토폴로지 | `docker-compose.full.yml` · `rails.config.distributed.yaml` |
|
||||
| **mock** | FSM 만 검증 (LLM/파일/push 없음) | `RAILS_TRANSPORT=mock` 또는 `rails run --mock` |
|
||||
|
||||
### LLM 제공자
|
||||
|
||||
어댑터가 있어 다음 중 하나를 선택할 수 있다. `.env` 의 `LLM_PROVIDER` 로 지정:
|
||||
|
||||
- `mock` — API 키 없이 결정론 스켈레톤만 확인 (기본값)
|
||||
- `openai` — OpenAI / OpenRouter / Azure OpenAI / OpenAI-호환 로컬 서버
|
||||
- `anthropic` — Anthropic Messages API
|
||||
- `ollama` — 로컬 Ollama 서버
|
||||
- `openclaw` — hanarang 내부 전용 런타임
|
||||
|
||||
## CLI 서브커맨드
|
||||
|
||||
@@ -165,7 +168,8 @@ AGENT_NAME=harang RAILS_API_URL=http://dev-vm:18800 node dist/server.js
|
||||
|
||||
## 문서
|
||||
|
||||
- [`docs/GUIDE.md`](docs/GUIDE.md) — **처음 보는 사람을 위한 완전 가이드 (전 구간 해설)**
|
||||
- [`docs/LOCAL-SETUP.md`](docs/LOCAL-SETUP.md) — **30 분 퀵스타트** (본인 환경에서 처음 돌려 보기)
|
||||
- [`docs/GUIDE.md`](docs/GUIDE.md) — **완전 가이드** (전 구간 해설, 처음 보는 사람용)
|
||||
- [`docs/GUIDE.pdf`](docs/GUIDE.pdf) — 위 문서의 PDF 버전
|
||||
- [`docs/migration-guide.md`](docs/migration-guide.md) — 레거시 → rails 이전 가이드
|
||||
- [`docs/operations.md`](docs/operations.md) — 운영 가이드 (PM2, 로그, DB)
|
||||
|
||||
141
docker-compose.full.yml
Normal file
141
docker-compose.full.yml
Normal file
@@ -0,0 +1,141 @@
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# docker-compose.full.yml — production-style distributed topology
|
||||
#
|
||||
# Brings up:
|
||||
# - mariadb
|
||||
# - rails (orchestrator only)
|
||||
# - sister-harang (plan)
|
||||
# - sister-narang (implement)
|
||||
# - sister-darang (review)
|
||||
# - sister-erang (deploy)
|
||||
#
|
||||
# All 6 services share the same image but each sister container runs the
|
||||
# sister-agent HTTP daemon instead of the rails orchestrator, and rails
|
||||
# is configured to talk to them over HTTP.
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env
|
||||
# docker compose -f docker-compose.full.yml up --build
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
x-sister-env: &sister-env
|
||||
NODE_ENV: production
|
||||
RAILS_API_URL: http://rails:18800
|
||||
SISTER_WORKSPACE_DIR: /app/rails-projects
|
||||
LLM_PROVIDER: ${LLM_PROVIDER:-mock}
|
||||
LLM_MODEL_MANAGER: ${LLM_MODEL_MANAGER:-}
|
||||
LLM_MODEL_PRINCIPAL: ${LLM_MODEL_PRINCIPAL:-}
|
||||
LLM_MODEL_LEAD: ${LLM_MODEL_LEAD:-}
|
||||
LLM_MODEL_JUNIOR: ${LLM_MODEL_JUNIOR:-}
|
||||
LLM_MODEL_FALLBACK: ${LLM_MODEL_FALLBACK:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-}
|
||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-}
|
||||
GITEA_BASE_URL: ${GITEA_BASE_URL:-}
|
||||
GITEA_ORG: ${GITEA_ORG:-}
|
||||
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
||||
|
||||
x-sister-service: &sister-service
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
command:
|
||||
["node", "sister-agent/dist/server.js"]
|
||||
networks:
|
||||
- rails-net
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:10.11
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: rootpw
|
||||
MARIADB_DATABASE: hanarang_rails
|
||||
MARIADB_USER: rails
|
||||
MARIADB_PASSWORD: rails
|
||||
healthcheck:
|
||||
test:
|
||||
- "CMD"
|
||||
- "healthcheck.sh"
|
||||
- "--connect"
|
||||
- "--innodb_initialized"
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
volumes:
|
||||
- rails-db:/var/lib/mysql
|
||||
networks:
|
||||
- rails-net
|
||||
|
||||
rails:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
sister-harang: { condition: service_started }
|
||||
sister-narang: { condition: service_started }
|
||||
sister-darang: { condition: service_started }
|
||||
sister-erang: { condition: service_started }
|
||||
environment:
|
||||
DATABASE_URL: mysql://rails:rails@mariadb:3306/hanarang_rails
|
||||
RAILS_PORT: "18800"
|
||||
RAILS_LOG_LEVEL: info
|
||||
NODE_ENV: production
|
||||
RAILS_TRANSPORT: http
|
||||
RAILS_API_URL: http://rails:18800
|
||||
SISTER_ENDPOINT_PLAN: http://sister-harang:18801
|
||||
SISTER_ENDPOINT_IMPLEMENT: http://sister-narang:18801
|
||||
SISTER_ENDPOINT_REVIEW: http://sister-darang:18801
|
||||
SISTER_ENDPOINT_DEPLOY: http://sister-erang:18801
|
||||
SISTER_NAME_PLAN: harang
|
||||
SISTER_NAME_IMPLEMENT: narang
|
||||
SISTER_NAME_REVIEW: darang
|
||||
SISTER_NAME_DEPLOY: erang
|
||||
ports:
|
||||
- "18800:18800"
|
||||
networks:
|
||||
- rails-net
|
||||
|
||||
sister-harang:
|
||||
<<: *sister-service
|
||||
environment:
|
||||
<<: *sister-env
|
||||
SISTER_AGENT_NAME: harang
|
||||
SISTER_AGENT_PORT: "18801"
|
||||
|
||||
sister-narang:
|
||||
<<: *sister-service
|
||||
environment:
|
||||
<<: *sister-env
|
||||
SISTER_AGENT_NAME: narang
|
||||
SISTER_AGENT_PORT: "18801"
|
||||
|
||||
sister-darang:
|
||||
<<: *sister-service
|
||||
environment:
|
||||
<<: *sister-env
|
||||
SISTER_AGENT_NAME: darang
|
||||
SISTER_AGENT_PORT: "18801"
|
||||
|
||||
sister-erang:
|
||||
<<: *sister-service
|
||||
environment:
|
||||
<<: *sister-env
|
||||
SISTER_AGENT_NAME: erang
|
||||
SISTER_AGENT_PORT: "18801"
|
||||
|
||||
volumes:
|
||||
rails-db:
|
||||
|
||||
networks:
|
||||
rails-net:
|
||||
driver: bridge
|
||||
96
docker-compose.yml
Normal file
96
docker-compose.yml
Normal file
@@ -0,0 +1,96 @@
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# docker-compose.yml — single-host / in-process mode
|
||||
#
|
||||
# Spins up:
|
||||
# - mariadb (10.11)
|
||||
# - rails (orchestrator + 4 sister agents all in one process)
|
||||
#
|
||||
# Everything runs in one container so the 4 sisters are just function
|
||||
# calls instead of 4 separate daemons. Pick this file when you want
|
||||
# "docker compose up and try it".
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env # set LLM_PROVIDER and API keys
|
||||
# docker compose up --build
|
||||
#
|
||||
# Then hit http://localhost:18800/health to confirm.
|
||||
#
|
||||
# For the production topology with 4 separate sister daemons, see
|
||||
# docker-compose.full.yml instead.
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:10.11
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: rootpw
|
||||
MARIADB_DATABASE: hanarang_rails
|
||||
MARIADB_USER: rails
|
||||
MARIADB_PASSWORD: rails
|
||||
healthcheck:
|
||||
test:
|
||||
- "CMD"
|
||||
- "healthcheck.sh"
|
||||
- "--connect"
|
||||
- "--innodb_initialized"
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
volumes:
|
||||
- rails-db:/var/lib/mysql
|
||||
networks:
|
||||
- rails-net
|
||||
|
||||
rails:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: mysql://rails:rails@mariadb:3306/hanarang_rails
|
||||
RAILS_PORT: "18800"
|
||||
RAILS_LOG_LEVEL: info
|
||||
NODE_ENV: production
|
||||
RAILS_TRANSPORT: in-process
|
||||
RAILS_API_URL: http://127.0.0.1:18800
|
||||
SISTER_AGENT_CORE_PATH: /app/sister-agent/dist/core.js
|
||||
SISTER_WORKSPACE_DIR: /app/rails-projects
|
||||
# LLM — read from .env
|
||||
LLM_PROVIDER: ${LLM_PROVIDER:-mock}
|
||||
LLM_MODEL_MANAGER: ${LLM_MODEL_MANAGER:-}
|
||||
LLM_MODEL_PRINCIPAL: ${LLM_MODEL_PRINCIPAL:-}
|
||||
LLM_MODEL_LEAD: ${LLM_MODEL_LEAD:-}
|
||||
LLM_MODEL_JUNIOR: ${LLM_MODEL_JUNIOR:-}
|
||||
LLM_MODEL_FALLBACK: ${LLM_MODEL_FALLBACK:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-}
|
||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-}
|
||||
# Gitea — leave empty to disable auto-push
|
||||
GITEA_BASE_URL: ${GITEA_BASE_URL:-}
|
||||
GITEA_ORG: ${GITEA_ORG:-}
|
||||
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
||||
ports:
|
||||
- "18800:18800"
|
||||
volumes:
|
||||
- rails-workspace:/app/rails-projects
|
||||
networks:
|
||||
- rails-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:18800/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
rails-db:
|
||||
rails-workspace:
|
||||
|
||||
networks:
|
||||
rails-net:
|
||||
driver: bridge
|
||||
218
docs/LOCAL-SETUP.md
Normal file
218
docs/LOCAL-SETUP.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Local Setup — 30 분 퀵스타트
|
||||
|
||||
이 문서는 **본인 환경에서 hanarang-rails 를 처음부터 돌려 보는** 가장 짧은 경로다. 외부 인프라 (Gitea, OpenClaw, 4 개 LXC, MariaDB 전용 서버) 전혀 없어도 로컬에서 E2E 파이프라인을 한 번 돌리는 게 목표.
|
||||
|
||||
대상 독자: 이 리포를 처음 클론한 사람. Node 와 docker 를 쓸 줄 아는 사람.
|
||||
|
||||
---
|
||||
|
||||
## 0. 사전 요구
|
||||
|
||||
하나만 선택:
|
||||
|
||||
- **Option A — Docker 경로** (권장): `docker` + `docker compose` 만 있으면 끝. MariaDB 까지 컨테이너로 뜬다.
|
||||
- **Option B — 네이티브 경로**: Node 22, pnpm 9, MariaDB 10.11+ 로컬 설치.
|
||||
|
||||
추가로 **LLM 제공자 하나**를 정해 둬야 한다.
|
||||
|
||||
| 제공자 | 필요한 것 | 비용 |
|
||||
|---|---|---|
|
||||
| `mock` | (없음) | 무료, 진짜 LLM 호출 없음 — FSM 만 확인 |
|
||||
| `openai` | OpenAI API 키 | 사용량 기반 |
|
||||
| `anthropic` | Anthropic API 키 | 사용량 기반 |
|
||||
| `ollama` | 로컬 Ollama + 모델 pull | 무료, 로컬 GPU/CPU |
|
||||
| `openclaw` | hanarang 내부 런타임 | 외부인 접근 불가 |
|
||||
|
||||
**처음이면 `mock` 으로 시작**하는 걸 권장한다. 실제 LLM 없이 파이프라인 전 구간이 동작하는지 먼저 확인하고, 그 다음 원하는 제공자로 바꿔도 늦지 않다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Docker 경로 (권장)
|
||||
|
||||
### 1-1. 클론 + 환경 설정
|
||||
|
||||
```bash
|
||||
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-rails.git
|
||||
cd hanarang-rails
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
`.env` 에서 최소 이 두 줄만 만져 주면 된다:
|
||||
|
||||
```bash
|
||||
# 모크 모드로 시작 (진짜 LLM 호출 안 함)
|
||||
LLM_PROVIDER=mock
|
||||
|
||||
# Docker compose 가 쓸 DB URL
|
||||
DATABASE_URL="mysql://rails:rails@mariadb:3306/hanarang_rails"
|
||||
```
|
||||
|
||||
### 1-2. 기동
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
처음 빌드는 몇 분 걸린다. 완료되면 rails 컨테이너가 마이그레이션을 돌리고 HTTP 서버가 18800 포트에서 리스닝한다.
|
||||
|
||||
```bash
|
||||
curl http://localhost:18800/health
|
||||
# → {"ok":true,"service":"hanarang-rails"}
|
||||
```
|
||||
|
||||
### 1-3. 파이프라인 첫 실행
|
||||
|
||||
다른 터미널에서:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:18800/pipelines/start \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"project":"todo-app","requirements":"간단한 todo 웹앱"}'
|
||||
```
|
||||
|
||||
응답으로 `pipelineId`, `finalState: done`, `transitions` 숫자가 돌아오면 성공. 파이프라인 상태는:
|
||||
|
||||
```bash
|
||||
curl http://localhost:18800/pipelines/<ID>
|
||||
```
|
||||
|
||||
### 1-4. 실제 LLM 로 갈아타기
|
||||
|
||||
`.env` 에서:
|
||||
|
||||
```bash
|
||||
LLM_PROVIDER=openai
|
||||
OPENAI_API_KEY=sk-...
|
||||
LLM_MODEL_MANAGER=gpt-4o
|
||||
LLM_MODEL_PRINCIPAL=gpt-4o
|
||||
LLM_MODEL_LEAD=gpt-4o-mini
|
||||
LLM_MODEL_JUNIOR=gpt-4o-mini
|
||||
```
|
||||
|
||||
`docker compose up -d --build` 로 재시작. 같은 `curl` 명령을 또 날리면 이번에는 실제 LLM 이 호출되고, 각 junior 가 만든 코드 블록이 `rails-workspace` 볼륨 안으로 저장된다.
|
||||
|
||||
> **Anthropic / Ollama / OpenAI 호환 서버** 도 같은 패턴이다. `LLM_PROVIDER` 만 바꾸고 해당 API 키/URL 를 `.env` 에 채워 주면 된다. `.env.example` 파일 주석에 각 제공자별 키 이름이 정리돼 있다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 네이티브 경로
|
||||
|
||||
Docker 없이 로컬 프로세스로 돌리는 경로.
|
||||
|
||||
### 2-1. MariaDB 준비
|
||||
|
||||
```bash
|
||||
# brew / apt / 도커 중 편한 방법으로 MariaDB 10.11+ 기동
|
||||
# 그 다음 DB/사용자 생성:
|
||||
mysql -u root -p <<SQL
|
||||
CREATE DATABASE hanarang_rails;
|
||||
CREATE USER 'rails'@'localhost' IDENTIFIED BY 'rails';
|
||||
GRANT ALL ON hanarang_rails.* TO 'rails'@'localhost';
|
||||
SQL
|
||||
```
|
||||
|
||||
### 2-2. 클론 + 빌드
|
||||
|
||||
```bash
|
||||
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-rails.git
|
||||
cd hanarang-rails
|
||||
pnpm install
|
||||
|
||||
# sister-agent 도 별도 install
|
||||
cd sister-agent && pnpm install && cd ..
|
||||
```
|
||||
|
||||
### 2-3. 환경 설정
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 편집:
|
||||
# DATABASE_URL="mysql://rails:rails@localhost:3306/hanarang_rails"
|
||||
# LLM_PROVIDER=mock
|
||||
# RAILS_TRANSPORT=in-process
|
||||
|
||||
cp rails.config.local.yaml rails.config.yaml
|
||||
```
|
||||
|
||||
### 2-4. DB 마이그레이션 + 빌드
|
||||
|
||||
```bash
|
||||
pnpm prisma migrate deploy
|
||||
pnpm prisma generate
|
||||
pnpm build
|
||||
cd sister-agent && pnpm build && cd ..
|
||||
```
|
||||
|
||||
### 2-5. 기동 + 테스트
|
||||
|
||||
```bash
|
||||
pnpm rails serve -c rails.config.yaml
|
||||
```
|
||||
|
||||
다른 터미널:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:18800/pipelines/start \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"project":"hello","requirements":"Say hi"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 파일은 어디로 가나?
|
||||
|
||||
- Docker 경로: rails 컨테이너의 `/app/rails-projects/<pipelineId>/<stage>/files/` 에 저장되고, `rails-workspace` named volume 에 영속화된다. 컨테이너 밖에서 보려면 `docker compose run --rm rails ls /app/rails-projects/<pipelineId>` 또는 볼륨 mount 변경.
|
||||
- 네이티브 경로: `$HOME/rails-projects/<pipelineId>/<stage>/files/`.
|
||||
|
||||
Gitea auto-push 는 기본적으로 꺼져 있다. 켜고 싶으면 `.env` 에 `GITEA_TOKEN`, `GITEA_BASE_URL`, `GITEA_ORG` 를 채우면 자동으로 켜진다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 대시보드도 띄우려면
|
||||
|
||||
대시보드 (`hanarang-dashboard`) 는 별도 리포다. rails 가 돌아가고 있는 상태에서 같은 MariaDB 를 바라보도록 설정하면 `/rails` 페이지에서 파이프라인 트리가 시각화된다.
|
||||
|
||||
```bash
|
||||
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard.git
|
||||
cd hanarang-dashboard/backend
|
||||
cp .env.example .env
|
||||
# DATABASE_URL 을 rails 와 같게
|
||||
# RAILS_API_URL=http://localhost:18800
|
||||
# GIT_RAW_ALLOWED_HOSTS=git.example.com (optional, for MD viewer)
|
||||
pnpm install && pnpm build && pnpm start:prod
|
||||
```
|
||||
|
||||
프론트엔드는 별도 프로세스:
|
||||
|
||||
```bash
|
||||
cd ../frontend
|
||||
pnpm install && pnpm dev
|
||||
# → http://localhost:3000/rails
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 자주 막히는 부분
|
||||
|
||||
**Q. `pnpm rails run` 이 "DATABASE_URL not set" 에러.**
|
||||
`.env` 가 rails 의 작업 디렉토리에 있어야 한다. `loadEnv()` 는 `process.cwd()` 기준으로 찾는다.
|
||||
|
||||
**Q. Mock 모드인데 LLM 응답이 텅 비어 있다.**
|
||||
정상이다. Mock 은 결정론 스켈레톤만 확인하려고 있는 거라 파일도 안 만들고 내용도 거의 없다. 실제 LLM 로 바꿔야 의미 있는 산출물이 나온다.
|
||||
|
||||
**Q. In-process 모드인데 `sister-agent core module not found`.**
|
||||
`sister-agent/dist/core.js` 가 빌드되지 않은 상태다. `cd sister-agent && pnpm build`. 또는 `SISTER_AGENT_CORE_PATH` 로 절대 경로 명시.
|
||||
|
||||
**Q. OpenAI 대신 OpenRouter / Azure OpenAI / 로컬 llama.cpp 서버를 쓸 수 있나?**
|
||||
된다. `LLM_PROVIDER=openai` 로 두고 `OPENAI_BASE_URL` 을 바꿔 주면 OpenAI Chat Completions 프로토콜을 말하는 모든 서버에 붙는다.
|
||||
|
||||
**Q. 4 개 자매를 진짜 분리된 컨테이너로 돌리고 싶다.**
|
||||
`docker-compose.full.yml` 을 써라. rails 1 개 + 각 자매 1 개씩 총 6 개 서비스가 뜨고, rails 가 HTTP 로 각 자매에게 invoke 를 보낸다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 다음 단계
|
||||
|
||||
- **구조 전체를 이해하고 싶다면**: [`docs/GUIDE.md`](GUIDE.md) 또는 PDF 버전
|
||||
- **실제로 코드를 건드리고 싶다면**: [`.plans/design/`](../.plans/design/) 의 설계 문서
|
||||
- **프롬프트/역할을 본인 도메인에 맞추고 싶다면**: `sister-agent/src/prompts.ts`, `sister-agent/src/roles.ts`, `rails.config.local.yaml` 순으로 읽기
|
||||
58
rails.config.distributed.yaml
Normal file
58
rails.config.distributed.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# rails.config.distributed.yaml
|
||||
#
|
||||
# Production-style topology. Each sister-agent runs as its own daemon
|
||||
# (typically on its own host/container/LXC) and rails calls them over
|
||||
# HTTP. This is what the hanarang internal deployment uses.
|
||||
#
|
||||
# Usage:
|
||||
# cp rails.config.distributed.yaml rails.config.yaml
|
||||
# # Start 4 sister-agent daemons (see docs/LOCAL-SETUP.md)
|
||||
# pnpm rails serve -c rails.config.yaml
|
||||
#
|
||||
# Endpoints can also be overridden via env:
|
||||
# SISTER_ENDPOINT_PLAN=http://host:18801 etc.
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
pipeline:
|
||||
stages:
|
||||
- plan
|
||||
- implement
|
||||
- review
|
||||
- deploy
|
||||
|
||||
agents:
|
||||
plan:
|
||||
role: plan
|
||||
displayName: Planner
|
||||
agentName: harang
|
||||
transport: http
|
||||
endpoint: http://harang.local:18801
|
||||
timeoutMs: 600000
|
||||
|
||||
implement:
|
||||
role: implement
|
||||
displayName: Generator
|
||||
agentName: narang
|
||||
transport: http
|
||||
endpoint: http://narang.local:18801
|
||||
timeoutMs: 600000
|
||||
|
||||
review:
|
||||
role: review
|
||||
displayName: Evaluator
|
||||
agentName: darang
|
||||
transport: http
|
||||
endpoint: http://darang.local:18801
|
||||
timeoutMs: 600000
|
||||
|
||||
deploy:
|
||||
role: deploy
|
||||
displayName: Deploy
|
||||
agentName: erang
|
||||
transport: http
|
||||
endpoint: http://erang.local:18801
|
||||
timeoutMs: 600000
|
||||
|
||||
discord:
|
||||
enabled: false
|
||||
56
rails.config.local.yaml
Normal file
56
rails.config.local.yaml
Normal file
@@ -0,0 +1,56 @@
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# rails.config.local.yaml
|
||||
#
|
||||
# Single-host / single-process configuration. Every stage runs the
|
||||
# sister-agent core directly inside the rails Node process — no separate
|
||||
# daemons, no networking between agents, just one binary.
|
||||
#
|
||||
# This is the fastest way to try rails on your laptop.
|
||||
#
|
||||
# Usage:
|
||||
# cp rails.config.local.yaml rails.config.yaml
|
||||
# cp .env.example .env # then set LLM_PROVIDER + any API keys
|
||||
# pnpm rails serve -c rails.config.yaml
|
||||
#
|
||||
# Env vars (RAILS_TRANSPORT, SISTER_ENDPOINT_*, etc.) always override
|
||||
# whatever is in this file.
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
pipeline:
|
||||
stages:
|
||||
- plan
|
||||
- implement
|
||||
- review
|
||||
- deploy
|
||||
|
||||
agents:
|
||||
plan:
|
||||
role: plan
|
||||
displayName: Planner
|
||||
agentName: harang
|
||||
transport: in-process
|
||||
timeoutMs: 600000
|
||||
|
||||
implement:
|
||||
role: implement
|
||||
displayName: Generator
|
||||
agentName: narang
|
||||
transport: in-process
|
||||
timeoutMs: 600000
|
||||
|
||||
review:
|
||||
role: review
|
||||
displayName: Evaluator
|
||||
agentName: darang
|
||||
transport: in-process
|
||||
timeoutMs: 600000
|
||||
|
||||
deploy:
|
||||
role: deploy
|
||||
displayName: Deploy
|
||||
agentName: erang
|
||||
transport: in-process
|
||||
timeoutMs: 600000
|
||||
|
||||
discord:
|
||||
enabled: false
|
||||
26
sister-agent/src/core.ts
Normal file
26
sister-agent/src/core.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Sister-agent library entry — exposes the core execution functions so
|
||||
* another process (e.g. rails running in single-process mode) can
|
||||
* invoke them directly without going through HTTP.
|
||||
*
|
||||
* This lives in addition to ./server.ts (which wraps the same logic as
|
||||
* an HTTP daemon). Both code paths share ./spawn.ts under the hood.
|
||||
*/
|
||||
|
||||
export { executeInvocation } from "./spawn.js";
|
||||
export { RailsClient } from "./rails-client.js";
|
||||
export { createLlmAdapter, getLlmAdapter } from "./llm/index.js";
|
||||
export type {
|
||||
LlmAdapter,
|
||||
LlmRequest,
|
||||
LlmResult,
|
||||
ProviderName,
|
||||
} from "./llm/index.js";
|
||||
export { ROLES, ROLE_KOREAN } from "./roles.js";
|
||||
export type { RoleConfig } from "./roles.js";
|
||||
export {
|
||||
InvokeRequest,
|
||||
HandoffMessage,
|
||||
Role,
|
||||
type SubTaskRecord,
|
||||
} from "./types.js";
|
||||
@@ -1,108 +1,11 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export interface LlmResult {
|
||||
ok: boolean;
|
||||
text: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
const OPENCLAW_BIN =
|
||||
process.env["OPENCLAW_BIN"] ??
|
||||
`${process.env["HOME"]}/.npm-global/bin/openclaw`;
|
||||
|
||||
/**
|
||||
* Call openclaw infer model run via subprocess.
|
||||
* Returns the structured JSON the CLI emits with --json.
|
||||
*
|
||||
* Note: openclaw enforces an allowlist per agent. We pass through to the
|
||||
* default model unless an explicit override is requested AND it's allowed.
|
||||
*/
|
||||
export async function callLlm(opts: {
|
||||
prompt: string;
|
||||
modelOverride?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<LlmResult> {
|
||||
const args = ["infer", "model", "run", "--prompt", opts.prompt, "--json"];
|
||||
if (opts.modelOverride) {
|
||||
args.push("--model", opts.modelOverride);
|
||||
}
|
||||
|
||||
return new Promise((resolveFn) => {
|
||||
const child = spawn(OPENCLAW_BIN, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill("SIGKILL");
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: "",
|
||||
model: opts.modelOverride ?? "default",
|
||||
errorMessage: `LLM timeout after ${opts.timeoutMs ?? 120_000}ms`,
|
||||
});
|
||||
}, opts.timeoutMs ?? 120_000);
|
||||
|
||||
child.stdout.on("data", (b: Buffer) => (stdout += b.toString()));
|
||||
child.stderr.on("data", (b: Buffer) => (stderr += b.toString()));
|
||||
child.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: "",
|
||||
model: opts.modelOverride ?? "default",
|
||||
errorMessage: `LLM spawn error: ${err.message}`,
|
||||
});
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
|
||||
if (code !== 0) {
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: "",
|
||||
model: opts.modelOverride ?? "default",
|
||||
errorMessage: `openclaw exit ${code}: ${stderr.slice(0, 500)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
ok: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
outputs: Array<{ text: string }>;
|
||||
};
|
||||
const text = parsed.outputs?.[0]?.text ?? "";
|
||||
resolveFn({
|
||||
ok: parsed.ok,
|
||||
text,
|
||||
provider: parsed.provider,
|
||||
model: parsed.model,
|
||||
});
|
||||
} catch (err) {
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: "",
|
||||
model: opts.modelOverride ?? "default",
|
||||
errorMessage: `JSON parse failed: ${err instanceof Error ? err.message : String(err)}\nstdout: ${stdout.slice(0, 500)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// Shim for backwards compatibility — the adapter implementations now live
|
||||
// in ./llm/. Import from ./llm/index.js for new code.
|
||||
export {
|
||||
callLlm,
|
||||
createLlmAdapter,
|
||||
getLlmAdapter,
|
||||
type LlmAdapter,
|
||||
type LlmRequest,
|
||||
type LlmResult,
|
||||
type ProviderName,
|
||||
} from "./llm/index.js";
|
||||
|
||||
18
sister-agent/src/llm/adapter.ts
Normal file
18
sister-agent/src/llm/adapter.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface LlmRequest {
|
||||
prompt: string;
|
||||
model: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface LlmResult {
|
||||
ok: boolean;
|
||||
text: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface LlmAdapter {
|
||||
readonly name: string;
|
||||
infer(req: LlmRequest): Promise<LlmResult>;
|
||||
}
|
||||
97
sister-agent/src/llm/anthropic.ts
Normal file
97
sister-agent/src/llm/anthropic.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
|
||||
|
||||
/**
|
||||
* Anthropic Messages API adapter.
|
||||
*
|
||||
* Required env:
|
||||
* ANTHROPIC_API_KEY — your API key
|
||||
* Optional env:
|
||||
* ANTHROPIC_BASE_URL — override endpoint (defaults to api.anthropic.com)
|
||||
* ANTHROPIC_VERSION — API version header (defaults to 2023-06-01)
|
||||
*/
|
||||
export class AnthropicAdapter implements LlmAdapter {
|
||||
readonly name = "anthropic";
|
||||
private readonly apiKey: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly version: string;
|
||||
|
||||
constructor(opts: { apiKey?: string; baseUrl?: string; version?: string } = {}) {
|
||||
this.apiKey = opts.apiKey ?? process.env["ANTHROPIC_API_KEY"] ?? "";
|
||||
this.baseUrl =
|
||||
opts.baseUrl ??
|
||||
process.env["ANTHROPIC_BASE_URL"] ??
|
||||
"https://api.anthropic.com";
|
||||
this.version =
|
||||
opts.version ?? process.env["ANTHROPIC_VERSION"] ?? "2023-06-01";
|
||||
}
|
||||
|
||||
async infer(req: LlmRequest): Promise<LlmResult> {
|
||||
if (!this.apiKey) {
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage: "ANTHROPIC_API_KEY not configured",
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), req.timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": this.apiKey,
|
||||
"anthropic-version": this.version,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: req.model,
|
||||
max_tokens: 4096,
|
||||
messages: [{ role: "user", content: req.prompt }],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage: `anthropic HTTP ${res.status}: ${body.slice(0, 400)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
model?: string;
|
||||
};
|
||||
const text =
|
||||
data.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join("") ?? "";
|
||||
return {
|
||||
ok: true,
|
||||
text,
|
||||
provider: this.name,
|
||||
model: data.model ?? req.model,
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
75
sister-agent/src/llm/index.ts
Normal file
75
sister-agent/src/llm/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
|
||||
import { OpenClawAdapter } from "./openclaw.js";
|
||||
import { OpenAiAdapter } from "./openai.js";
|
||||
import { AnthropicAdapter } from "./anthropic.js";
|
||||
import { OllamaAdapter } from "./ollama.js";
|
||||
import { MockAdapter } from "./mock.js";
|
||||
|
||||
export type { LlmAdapter, LlmRequest, LlmResult };
|
||||
|
||||
export type ProviderName =
|
||||
| "openclaw"
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "ollama"
|
||||
| "mock";
|
||||
|
||||
/**
|
||||
* Build an adapter from env / explicit override.
|
||||
*
|
||||
* Provider selection order:
|
||||
* 1. explicit `opts.provider`
|
||||
* 2. env LLM_PROVIDER
|
||||
* 3. default: "mock" (safe fallback — won't accidentally spend money)
|
||||
*/
|
||||
export function createLlmAdapter(
|
||||
opts: { provider?: ProviderName } = {},
|
||||
): LlmAdapter {
|
||||
const raw =
|
||||
opts.provider ??
|
||||
(process.env["LLM_PROVIDER"] as ProviderName | undefined) ??
|
||||
"mock";
|
||||
|
||||
switch (raw) {
|
||||
case "openclaw":
|
||||
return new OpenClawAdapter();
|
||||
case "openai":
|
||||
return new OpenAiAdapter();
|
||||
case "anthropic":
|
||||
return new AnthropicAdapter();
|
||||
case "ollama":
|
||||
return new OllamaAdapter();
|
||||
case "mock":
|
||||
return new MockAdapter();
|
||||
default: {
|
||||
const exhaustive: never = raw;
|
||||
throw new Error(
|
||||
`Unknown LLM_PROVIDER: ${exhaustive as string}. ` +
|
||||
`Supported: openclaw, openai, anthropic, ollama, mock`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Module-level singleton so we don't rebuild the adapter on every LLM call.
|
||||
let cached: LlmAdapter | null = null;
|
||||
|
||||
export function getLlmAdapter(): LlmAdapter {
|
||||
if (!cached) cached = createLlmAdapter();
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Convenience wrapper — kept signature-compatible with the old callLlm(). */
|
||||
export async function callLlm(opts: {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
modelOverride?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<LlmResult> {
|
||||
const adapter = getLlmAdapter();
|
||||
return adapter.infer({
|
||||
prompt: opts.prompt,
|
||||
model: opts.modelOverride ?? opts.model ?? "",
|
||||
timeoutMs: opts.timeoutMs ?? 120_000,
|
||||
});
|
||||
}
|
||||
42
sister-agent/src/llm/mock.ts
Normal file
42
sister-agent/src/llm/mock.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
|
||||
|
||||
/**
|
||||
* Mock adapter — returns a deterministic fake response based on the role
|
||||
* hint in the prompt. Useful for smoke tests and offline demos where no
|
||||
* real LLM credentials are available.
|
||||
*/
|
||||
export class MockAdapter implements LlmAdapter {
|
||||
readonly name = "mock";
|
||||
|
||||
async infer(req: LlmRequest): Promise<LlmResult> {
|
||||
// tiny delay so upstream concurrency code behaves as if it's async
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
|
||||
const isImpl = /implement|구현/.test(req.prompt);
|
||||
const isJunior = /junior|신입/.test(req.prompt);
|
||||
|
||||
let text: string;
|
||||
if (isImpl && isJunior) {
|
||||
text = [
|
||||
"간단한 샘플 산출물입니다.",
|
||||
"",
|
||||
"```html:frontend/index.html",
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head><meta charset=\"utf-8\"><title>mock</title></head>",
|
||||
"<body><h1>Hello from mock adapter</h1></body>",
|
||||
"</html>",
|
||||
"```",
|
||||
].join("\n");
|
||||
} else {
|
||||
text = `# Mock ${req.model}\n\n이 응답은 MockAdapter 가 생성한 결정론적 더미입니다. 실제 LLM 응답이 아닙니다.`;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
text,
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
72
sister-agent/src/llm/ollama.ts
Normal file
72
sister-agent/src/llm/ollama.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
|
||||
|
||||
/**
|
||||
* Ollama adapter — for local LLM inference via https://ollama.com
|
||||
*
|
||||
* Optional env:
|
||||
* OLLAMA_BASE_URL — defaults to http://localhost:11434
|
||||
*
|
||||
* Example model names: llama3.1, qwen2.5-coder, mistral, deepseek-coder
|
||||
*/
|
||||
export class OllamaAdapter implements LlmAdapter {
|
||||
readonly name = "ollama";
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(opts: { baseUrl?: string } = {}) {
|
||||
this.baseUrl =
|
||||
opts.baseUrl ??
|
||||
process.env["OLLAMA_BASE_URL"] ??
|
||||
"http://localhost:11434";
|
||||
}
|
||||
|
||||
async infer(req: LlmRequest): Promise<LlmResult> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), req.timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: req.model,
|
||||
prompt: req.prompt,
|
||||
stream: false,
|
||||
options: { temperature: 0.3 },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage: `ollama HTTP ${res.status}: ${body.slice(0, 400)}`,
|
||||
};
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
response?: string;
|
||||
model?: string;
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
text: data.response ?? "",
|
||||
provider: this.name,
|
||||
model: data.model ?? req.model,
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
91
sister-agent/src/llm/openai.ts
Normal file
91
sister-agent/src/llm/openai.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
|
||||
|
||||
/**
|
||||
* OpenAI Chat Completions adapter.
|
||||
*
|
||||
* Required env:
|
||||
* OPENAI_API_KEY — your API key
|
||||
* Optional env:
|
||||
* OPENAI_BASE_URL — override endpoint (defaults to api.openai.com/v1)
|
||||
* Use this for Azure OpenAI, OpenRouter, local
|
||||
* llama.cpp servers that speak the OpenAI protocol,
|
||||
* etc.
|
||||
*/
|
||||
export class OpenAiAdapter implements LlmAdapter {
|
||||
readonly name = "openai";
|
||||
private readonly apiKey: string;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(opts: { apiKey?: string; baseUrl?: string } = {}) {
|
||||
this.apiKey = opts.apiKey ?? process.env["OPENAI_API_KEY"] ?? "";
|
||||
this.baseUrl =
|
||||
opts.baseUrl ??
|
||||
process.env["OPENAI_BASE_URL"] ??
|
||||
"https://api.openai.com/v1";
|
||||
}
|
||||
|
||||
async infer(req: LlmRequest): Promise<LlmResult> {
|
||||
if (!this.apiKey) {
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage: "OPENAI_API_KEY not configured",
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), req.timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: req.model,
|
||||
messages: [{ role: "user", content: req.prompt }],
|
||||
temperature: 0.3,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage: `openai HTTP ${res.status}: ${body.slice(0, 400)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
model?: string;
|
||||
};
|
||||
const text = data.choices?.[0]?.message?.content ?? "";
|
||||
return {
|
||||
ok: true,
|
||||
text,
|
||||
provider: this.name,
|
||||
model: data.model ?? req.model,
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model,
|
||||
errorMessage:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
103
sister-agent/src/llm/openclaw.ts
Normal file
103
sister-agent/src/llm/openclaw.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
|
||||
|
||||
/**
|
||||
* OpenClaw adapter — invokes the nabomhalang internal OpenClaw runtime via its
|
||||
* `openclaw infer model run --json` subprocess. This is the original adapter
|
||||
* used by the hanarang 4-sister deployment.
|
||||
*
|
||||
* External users will most likely NOT have OpenClaw installed. They should
|
||||
* use the `openai`, `anthropic`, `ollama`, or `mock` adapters instead.
|
||||
*/
|
||||
export class OpenClawAdapter implements LlmAdapter {
|
||||
readonly name = "openclaw";
|
||||
private readonly bin: string;
|
||||
|
||||
constructor(opts: { bin?: string } = {}) {
|
||||
this.bin =
|
||||
opts.bin ??
|
||||
process.env["OPENCLAW_BIN"] ??
|
||||
`${process.env["HOME"]}/.npm-global/bin/openclaw`;
|
||||
}
|
||||
|
||||
async infer(req: LlmRequest): Promise<LlmResult> {
|
||||
const args = ["infer", "model", "run", "--prompt", req.prompt, "--json"];
|
||||
if (req.model) args.push("--model", req.model);
|
||||
|
||||
return new Promise((resolveFn) => {
|
||||
const child = spawn(this.bin, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill("SIGKILL");
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model || "default",
|
||||
errorMessage: `openclaw timeout after ${req.timeoutMs}ms`,
|
||||
});
|
||||
}, req.timeoutMs);
|
||||
|
||||
child.stdout.on("data", (b: Buffer) => (stdout += b.toString()));
|
||||
child.stderr.on("data", (b: Buffer) => (stderr += b.toString()));
|
||||
child.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model || "default",
|
||||
errorMessage: `openclaw spawn error: ${err.message}`,
|
||||
});
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model || "default",
|
||||
errorMessage: `openclaw exit ${code}: ${stderr.slice(0, 500)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
ok: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
outputs: Array<{ text: string }>;
|
||||
};
|
||||
resolveFn({
|
||||
ok: parsed.ok,
|
||||
text: parsed.outputs?.[0]?.text ?? "",
|
||||
provider: parsed.provider || this.name,
|
||||
model: parsed.model || req.model,
|
||||
});
|
||||
} catch (err) {
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: this.name,
|
||||
model: req.model || "default",
|
||||
errorMessage: `openclaw JSON parse failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,28 +7,71 @@ export interface RoleConfig {
|
||||
maxSpawnPerCall: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-role model defaults. For external users this is almost certainly the
|
||||
* first thing you'll want to change — the `gpt-5.4` / `glm-*` / `gpt-codex-*`
|
||||
* names are OpenClaw-internal labels that won't resolve against OpenAI,
|
||||
* Anthropic, or Ollama directly.
|
||||
*
|
||||
* Override priority (highest first):
|
||||
* 1. env vars:
|
||||
* LLM_MODEL_MANAGER, LLM_MODEL_PRINCIPAL, LLM_MODEL_LEAD, LLM_MODEL_JUNIOR
|
||||
* LLM_MODEL_FALLBACK (used for every role's fallback unless you set
|
||||
* LLM_MODEL_FALLBACK_<ROLE>)
|
||||
* 2. these hardcoded defaults (OpenClaw-flavored)
|
||||
*
|
||||
* Good starting points for a real deployment:
|
||||
* OpenAI: gpt-4o / gpt-4o-mini
|
||||
* Anthropic: claude-opus-4-6 / claude-haiku-4-5
|
||||
* Ollama: qwen2.5-coder:32b / qwen2.5-coder:7b
|
||||
*/
|
||||
|
||||
const OPENCLAW_DEFAULTS: Record<Role, { primary: string; fallback: string }> = {
|
||||
manager: { primary: "gpt-5.4", fallback: "glm-5.1" },
|
||||
principal: { primary: "gpt-5.4", fallback: "glm-5.1" },
|
||||
lead: { primary: "gpt-codex-5.3", fallback: "glm-5" },
|
||||
junior: { primary: "glm-5-turbo", fallback: "gpt-5" },
|
||||
};
|
||||
|
||||
function envModel(role: Role, kind: "primary" | "fallback"): string | undefined {
|
||||
const up = role.toUpperCase();
|
||||
if (kind === "primary") {
|
||||
return process.env[`LLM_MODEL_${up}`];
|
||||
}
|
||||
return (
|
||||
process.env[`LLM_MODEL_FALLBACK_${up}`] ??
|
||||
process.env["LLM_MODEL_FALLBACK"]
|
||||
);
|
||||
}
|
||||
|
||||
function modelFor(role: Role, kind: "primary" | "fallback"): string {
|
||||
const override = envModel(role, kind);
|
||||
if (override && override.length > 0) return override;
|
||||
return OPENCLAW_DEFAULTS[role][kind];
|
||||
}
|
||||
|
||||
export const ROLES: Record<Role, RoleConfig> = {
|
||||
manager: {
|
||||
primaryModel: "gpt-5.4",
|
||||
fallbackModel: "glm-5.1",
|
||||
primaryModel: modelFor("manager", "primary"),
|
||||
fallbackModel: modelFor("manager", "fallback"),
|
||||
canSpawn: ["principal", "lead", "junior"],
|
||||
maxSpawnPerCall: 4,
|
||||
},
|
||||
principal: {
|
||||
primaryModel: "gpt-5.4",
|
||||
fallbackModel: "glm-5.1",
|
||||
primaryModel: modelFor("principal", "primary"),
|
||||
fallbackModel: modelFor("principal", "fallback"),
|
||||
canSpawn: ["lead", "junior"],
|
||||
maxSpawnPerCall: 3,
|
||||
},
|
||||
lead: {
|
||||
primaryModel: "gpt-codex-5.3",
|
||||
fallbackModel: "glm-5",
|
||||
primaryModel: modelFor("lead", "primary"),
|
||||
fallbackModel: modelFor("lead", "fallback"),
|
||||
canSpawn: ["junior"],
|
||||
maxSpawnPerCall: 4,
|
||||
},
|
||||
junior: {
|
||||
primaryModel: "glm-5-turbo",
|
||||
fallbackModel: "gpt-5",
|
||||
primaryModel: modelFor("junior", "primary"),
|
||||
fallbackModel: modelFor("junior", "fallback"),
|
||||
canSpawn: [],
|
||||
maxSpawnPerCall: 0,
|
||||
},
|
||||
|
||||
@@ -20,8 +20,24 @@ import { buildPrompt } from "./prompts.js";
|
||||
import { extractCodeBlocks, saveExtractedFiles } from "./code-extractor.js";
|
||||
import { commitAndPush } from "./git-ops.js";
|
||||
|
||||
const USE_REAL_LLM = process.env["RAILS_USE_REAL_LLM"] !== "false";
|
||||
const ENABLE_GIT_PUSH = process.env["RAILS_ENABLE_GIT_PUSH"] !== "false";
|
||||
// Real LLM call is the default. Set USE_REAL_LLM=false (or the legacy
|
||||
// RAILS_USE_REAL_LLM=false) to short-circuit every LLM call — useful when
|
||||
// the operator wants determinism-only smoke tests.
|
||||
const USE_REAL_LLM =
|
||||
process.env["USE_REAL_LLM"] !== "false" &&
|
||||
process.env["RAILS_USE_REAL_LLM"] !== "false";
|
||||
|
||||
// Git push auto-activates when a Gitea token is present. Operators can
|
||||
// force it off (useful for local dry-runs) by setting GIT_PUSH_ENABLED=false.
|
||||
// The legacy RAILS_ENABLE_GIT_PUSH env var is still honored.
|
||||
const ENABLE_GIT_PUSH = (() => {
|
||||
if (process.env["GIT_PUSH_ENABLED"] === "false") return false;
|
||||
if (process.env["RAILS_ENABLE_GIT_PUSH"] === "false") return false;
|
||||
if (process.env["GIT_PUSH_ENABLED"] === "true") return true;
|
||||
if (process.env["RAILS_ENABLE_GIT_PUSH"] === "true") return true;
|
||||
// Auto: enabled iff we actually have a token to push with
|
||||
return Boolean(process.env["GITEA_TOKEN"]);
|
||||
})();
|
||||
const WORKSPACE_ROOT =
|
||||
process.env["SISTER_WORKSPACE_DIR"] ??
|
||||
join(homedir(), "rails-projects");
|
||||
@@ -328,6 +344,7 @@ async function doWork(args: {
|
||||
|
||||
const result = await callLlm({
|
||||
prompt,
|
||||
model: ROLES[args.role].primaryModel,
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { loadConfig } from "../config/loader.js";
|
||||
import { runPipeline } from "../orchestrator/runner.js";
|
||||
import { MockTransport } from "../handoff/mock-transport.js";
|
||||
import type { SisterTransport } from "../handoff/transport.js";
|
||||
import { buildTransports } from "../handoff/build.js";
|
||||
import { disconnectPrisma } from "../orchestrator/persist.js";
|
||||
|
||||
export default defineCommand({
|
||||
@@ -39,31 +40,16 @@ export default defineCommand({
|
||||
loadEnv();
|
||||
try {
|
||||
const config = await loadConfig(args.config || undefined);
|
||||
const transports = new Map<string, SisterTransport>();
|
||||
|
||||
let transports: Map<string, SisterTransport>;
|
||||
if (args.mock) {
|
||||
// Hard override: force mock across all stages
|
||||
const mock = new MockTransport();
|
||||
for (const stage of config.pipeline.stages) {
|
||||
transports.set(stage, mock);
|
||||
}
|
||||
transports = new Map();
|
||||
for (const stage of config.pipeline.stages) transports.set(stage, mock);
|
||||
} else {
|
||||
// For now, default to mock when no real transport wiring is provided.
|
||||
// Sprint 004 ships DiscordTransport as a class; wiring a live discord
|
||||
// client is an operator task (see docs/discord-setup.md).
|
||||
const mock = new MockTransport();
|
||||
for (const stage of config.pipeline.stages) {
|
||||
const t = config.agents[stage]?.transport;
|
||||
if (t === "mock" || !t) {
|
||||
transports.set(stage, mock);
|
||||
} else if (t === "discord") {
|
||||
console.warn(
|
||||
`[rails] Discord transport for stage '${stage}' requires a bot wiring — falling back to mock.`,
|
||||
);
|
||||
transports.set(stage, mock);
|
||||
} else {
|
||||
transports.set(stage, mock);
|
||||
}
|
||||
}
|
||||
// Config + env-based transport wiring
|
||||
transports = buildTransports(config);
|
||||
}
|
||||
|
||||
const result = await runPipeline({
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TransportMode = z.enum(["discord", "mock", "local"]);
|
||||
export const TransportMode = z.enum([
|
||||
"discord",
|
||||
"mock",
|
||||
"local",
|
||||
"http",
|
||||
"in-process",
|
||||
]);
|
||||
export type TransportMode = z.infer<typeof TransportMode>;
|
||||
|
||||
export const AgentConfig = z.object({
|
||||
role: z.string().min(1),
|
||||
displayName: z.string().default(""),
|
||||
/** Sister identity — harang / narang / darang / erang. Required for http / in-process. */
|
||||
agentName: z.string().default(""),
|
||||
transport: TransportMode.default("mock"),
|
||||
channelId: z.string().default(""),
|
||||
/** http transport: the sister-agent daemon endpoint, e.g. http://10.10.10.112:18801 */
|
||||
endpoint: z.string().default(""),
|
||||
/** in-process: optional override for the sister-agent core module path */
|
||||
coreModulePath: z.string().default(""),
|
||||
timeoutMs: z.number().int().positive().default(600_000),
|
||||
});
|
||||
export type AgentConfig = z.infer<typeof AgentConfig>;
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
import type { SisterTransport } from "./transport.js";
|
||||
import { MockTransport } from "./mock-transport.js";
|
||||
import { HttpTransport } from "./http-transport.js";
|
||||
import type { RailsConfig } from "../config/schema.js";
|
||||
import { InProcessTransport } from "./in-process-transport.js";
|
||||
import type { RailsConfig, TransportMode } from "../config/schema.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "transport-builder" });
|
||||
|
||||
const DEFAULT_SISTER_NAMES: Record<string, string> = {
|
||||
plan: "harang",
|
||||
implement: "narang",
|
||||
review: "darang",
|
||||
deploy: "erang",
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a stage → transport map from rails config + environment.
|
||||
*
|
||||
* Environment overrides (convenient for testing):
|
||||
* RAILS_TRANSPORT_MODE=mock|http|auto (default auto — use config)
|
||||
* RAILS_API_URL=http://10.10.10.169:18800 (used as rails callback URL for sub-tasks)
|
||||
* RAILS_AGENT_{STAGE}_HOST=10.10.10.112 (override agent host)
|
||||
* RAILS_AGENT_{STAGE}_PORT=18801 (override agent port)
|
||||
* Environment overrides (convenient for docker-compose / smoke tests):
|
||||
*
|
||||
* RAILS_TRANSPORT=mock|http|in-process (applies to every stage)
|
||||
* RAILS_TRANSPORT_{STAGE}=… (per-stage override)
|
||||
* RAILS_API_URL=http://127.0.0.1:18800 (callback URL for sub-tasks)
|
||||
*
|
||||
* For http transport:
|
||||
* SISTER_ENDPOINT_{STAGE}=http://host:18801
|
||||
* or legacy RAILS_AGENT_{STAGE}_HOST / _PORT
|
||||
*
|
||||
* For in-process transport:
|
||||
* SISTER_AGENT_CORE_PATH=/abs/path/to/sister-agent/dist/core.js
|
||||
*
|
||||
* Sister identity:
|
||||
* SISTER_NAME_{STAGE}=harang|narang|darang|erang|custom
|
||||
*/
|
||||
export function buildTransports(config: RailsConfig): Map<string, SisterTransport> {
|
||||
export function buildTransports(
|
||||
config: RailsConfig,
|
||||
): Map<string, SisterTransport> {
|
||||
const transports = new Map<string, SisterTransport>();
|
||||
const mode = process.env["RAILS_TRANSPORT_MODE"] ?? "auto";
|
||||
const railsApiUrl =
|
||||
process.env["RAILS_API_URL"] ?? "http://127.0.0.1:18800";
|
||||
|
||||
@@ -25,59 +44,106 @@ export function buildTransports(config: RailsConfig): Map<string, SisterTranspor
|
||||
|
||||
for (const stage of config.pipeline.stages) {
|
||||
const agentConfig = config.agents[stage];
|
||||
const configured = agentConfig?.transport ?? "mock";
|
||||
const mode = resolveMode(stage, configured);
|
||||
const agentName = sisterName(stage, agentConfig?.agentName ?? "");
|
||||
const timeoutMs = agentConfig?.timeoutMs ?? 600_000;
|
||||
|
||||
// Explicit mode override
|
||||
if (mode === "mock") {
|
||||
transports.set(stage, sharedMock);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine desired transport
|
||||
const desired = mode === "http" ? "http" : agentConfig?.transport ?? "mock";
|
||||
|
||||
if (desired === "mock") {
|
||||
transports.set(stage, sharedMock);
|
||||
log.info({ stage, transport: "mock" }, "transport wired");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (desired === "http") {
|
||||
const hostEnv = `RAILS_AGENT_${stage.toUpperCase()}_HOST`;
|
||||
const portEnv = `RAILS_AGENT_${stage.toUpperCase()}_PORT`;
|
||||
const host = process.env[hostEnv];
|
||||
const port = process.env[portEnv] ?? "18801";
|
||||
|
||||
if (!host) {
|
||||
log.warn(
|
||||
{ stage, missing: hostEnv },
|
||||
"HTTP transport requested but host env missing — falling back to mock",
|
||||
);
|
||||
switch (mode) {
|
||||
case "mock":
|
||||
case "local":
|
||||
transports.set(stage, sharedMock);
|
||||
continue;
|
||||
log.info({ stage, transport: "mock" }, "transport wired");
|
||||
break;
|
||||
|
||||
case "in-process": {
|
||||
const coreOverride =
|
||||
agentConfig?.coreModulePath || undefined;
|
||||
transports.set(
|
||||
stage,
|
||||
new InProcessTransport({
|
||||
agentName,
|
||||
railsApiUrl,
|
||||
...(coreOverride ? { coreModulePath: coreOverride } : {}),
|
||||
timeoutMs,
|
||||
}),
|
||||
);
|
||||
log.info(
|
||||
{ stage, transport: "in-process", agentName },
|
||||
"transport wired",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const endpoint = `http://${host}:${port}`;
|
||||
const agentName = agentConfig?.role ?? stage;
|
||||
transports.set(
|
||||
stage,
|
||||
new HttpTransport({
|
||||
agentName,
|
||||
endpoint,
|
||||
railsApiUrl,
|
||||
timeoutMs: agentConfig?.timeoutMs ?? 600_000,
|
||||
}),
|
||||
);
|
||||
log.info(
|
||||
{ stage, transport: "http", endpoint, agentName },
|
||||
"transport wired",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
case "http": {
|
||||
const endpoint = sisterEndpoint(stage, agentConfig?.endpoint ?? "");
|
||||
if (!endpoint) {
|
||||
log.warn(
|
||||
{ stage },
|
||||
"http transport requested but endpoint missing — falling back to mock",
|
||||
);
|
||||
transports.set(stage, sharedMock);
|
||||
break;
|
||||
}
|
||||
transports.set(
|
||||
stage,
|
||||
new HttpTransport({
|
||||
agentName,
|
||||
endpoint,
|
||||
railsApiUrl,
|
||||
timeoutMs,
|
||||
}),
|
||||
);
|
||||
log.info(
|
||||
{ stage, transport: "http", endpoint, agentName },
|
||||
"transport wired",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Unknown — fall back to mock
|
||||
transports.set(stage, sharedMock);
|
||||
log.warn({ stage, desired }, "unknown transport, using mock");
|
||||
case "discord":
|
||||
log.warn(
|
||||
{ stage },
|
||||
"discord transport not wired — falling back to mock",
|
||||
);
|
||||
transports.set(stage, sharedMock);
|
||||
break;
|
||||
|
||||
default: {
|
||||
const exhaustive: never = mode;
|
||||
void exhaustive;
|
||||
transports.set(stage, sharedMock);
|
||||
log.warn({ stage, mode }, "unknown transport — using mock");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return transports;
|
||||
}
|
||||
|
||||
function resolveMode(stage: string, configured: TransportMode): TransportMode {
|
||||
const perStage = process.env[`RAILS_TRANSPORT_${stage.toUpperCase()}`];
|
||||
const global = process.env["RAILS_TRANSPORT"];
|
||||
// Legacy env (kept for backwards compatibility with existing deployments)
|
||||
const legacy = process.env["RAILS_TRANSPORT_MODE"];
|
||||
const raw = perStage ?? global ?? (legacy && legacy !== "auto" ? legacy : undefined) ?? configured;
|
||||
return raw as TransportMode;
|
||||
}
|
||||
|
||||
function sisterEndpoint(stage: string, configured: string): string {
|
||||
const perStage = process.env[`SISTER_ENDPOINT_${stage.toUpperCase()}`];
|
||||
if (perStage) return perStage;
|
||||
if (configured) return configured;
|
||||
// Legacy host/port style
|
||||
const host = process.env[`RAILS_AGENT_${stage.toUpperCase()}_HOST`];
|
||||
const port = process.env[`RAILS_AGENT_${stage.toUpperCase()}_PORT`] ?? "18801";
|
||||
if (host) return `http://${host}:${port}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function sisterName(stage: string, configured: string): string {
|
||||
if (configured) return configured;
|
||||
const perStage = process.env[`SISTER_NAME_${stage.toUpperCase()}`];
|
||||
if (perStage) return perStage;
|
||||
return DEFAULT_SISTER_NAMES[stage] ?? stage;
|
||||
}
|
||||
|
||||
45
src/handoff/direct-rails-client.ts
Normal file
45
src/handoff/direct-rails-client.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
CreateSubTaskInput,
|
||||
SubTaskEventInput,
|
||||
UpdateSubTaskInput,
|
||||
createSubTask,
|
||||
updateSubTask,
|
||||
recordSubTaskEvent,
|
||||
} from "../hierarchy/store.js";
|
||||
|
||||
/**
|
||||
* In-process replacement for sister-agent's HTTP-based RailsClient.
|
||||
*
|
||||
* When rails runs in single-process mode there's no point going through
|
||||
* an HTTP loopback to write sub-task events — we can call the store
|
||||
* directly. This class is duck-type compatible with the sister-agent
|
||||
* RailsClient (same 3 methods) so InProcessTransport can pass it in place
|
||||
* of the real client.
|
||||
*/
|
||||
export class DirectRailsClient {
|
||||
async createSubTask(record: unknown): Promise<void> {
|
||||
const parsed = CreateSubTaskInput.parse(record);
|
||||
await createSubTask(parsed);
|
||||
}
|
||||
|
||||
async recordEvent(
|
||||
subTaskId: string,
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const parsed = SubTaskEventInput.parse({
|
||||
subTaskId,
|
||||
eventType,
|
||||
payload,
|
||||
});
|
||||
await recordSubTaskEvent(parsed);
|
||||
}
|
||||
|
||||
async patchSubTask(
|
||||
id: string,
|
||||
patch: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const parsed = UpdateSubTaskInput.parse(patch);
|
||||
await updateSubTask(id, parsed);
|
||||
}
|
||||
}
|
||||
122
src/handoff/in-process-transport.ts
Normal file
122
src/handoff/in-process-transport.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
import type { SisterTransport, HealthStatus } from "./transport.js";
|
||||
import { HandoffMessage, type InvokeRequest } from "./message.js";
|
||||
import { DirectRailsClient } from "./direct-rails-client.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "in-process-transport" });
|
||||
|
||||
export interface InProcessTransportOptions {
|
||||
/** Agent identity — harang / narang / darang / erang (or any custom name). */
|
||||
agentName: string;
|
||||
/**
|
||||
* Rails API URL (loopback). The embedded sister-agent core uses this to
|
||||
* report sub-task events back via HTTP. Typically "http://127.0.0.1:<port>".
|
||||
*/
|
||||
railsApiUrl: string;
|
||||
/**
|
||||
* Absolute path to sister-agent's compiled core.js. Defaults to
|
||||
* env SISTER_AGENT_CORE_PATH
|
||||
* or <cwd>/sister-agent/dist/core.js
|
||||
*/
|
||||
coreModulePath?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface SisterCore {
|
||||
executeInvocation: (req: unknown, rails: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
let cachedCore: Promise<SisterCore> | null = null;
|
||||
|
||||
function loadCore(modulePath: string): Promise<SisterCore> {
|
||||
if (!cachedCore) {
|
||||
const url = pathToFileURL(resolve(modulePath)).href;
|
||||
cachedCore = import(url).then((mod: unknown) => {
|
||||
const m = mod as Partial<SisterCore>;
|
||||
if (typeof m.executeInvocation !== "function") {
|
||||
throw new Error(
|
||||
`sister-agent core module at ${modulePath} is missing executeInvocation export`,
|
||||
);
|
||||
}
|
||||
return m as SisterCore;
|
||||
});
|
||||
}
|
||||
return cachedCore;
|
||||
}
|
||||
|
||||
/**
|
||||
* InProcessTransport — runs sister-agent logic inside the same Node process
|
||||
* as rails. Used for single-host, zero-config deployments where spinning up
|
||||
* 4 separate LXCs is overkill.
|
||||
*
|
||||
* Under the hood it dynamically imports sister-agent/dist/core.js and calls
|
||||
* executeInvocation() directly. Sub-task events still flow through the rails
|
||||
* HTTP API (loopback) so the observability surface is identical to the
|
||||
* distributed HTTP transport.
|
||||
*/
|
||||
export class InProcessTransport implements SisterTransport {
|
||||
readonly name: string;
|
||||
private readonly opts: Required<InProcessTransportOptions>;
|
||||
|
||||
constructor(opts: InProcessTransportOptions) {
|
||||
this.name = `in-process:${opts.agentName}`;
|
||||
this.opts = {
|
||||
agentName: opts.agentName,
|
||||
railsApiUrl: opts.railsApiUrl,
|
||||
coreModulePath:
|
||||
opts.coreModulePath ??
|
||||
process.env["SISTER_AGENT_CORE_PATH"] ??
|
||||
resolve(process.cwd(), "sister-agent/dist/core.js"),
|
||||
timeoutMs: opts.timeoutMs ?? 600_000,
|
||||
};
|
||||
}
|
||||
|
||||
async invoke(
|
||||
req: InvokeRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HandoffMessage> {
|
||||
const core = await loadCore(this.opts.coreModulePath);
|
||||
// Use a direct-DB client — skipping HTTP loopback entirely.
|
||||
const rails = new DirectRailsClient();
|
||||
|
||||
const payload = {
|
||||
...req,
|
||||
agentName: this.opts.agentName,
|
||||
railsApiUrl: this.opts.railsApiUrl,
|
||||
timeoutMs: req.timeoutMs || this.opts.timeoutMs,
|
||||
};
|
||||
|
||||
log.info(
|
||||
{ agent: this.opts.agentName, stage: req.stage, pipelineId: req.pipelineId },
|
||||
"in-process invoke start",
|
||||
);
|
||||
|
||||
// The caller's AbortSignal is honored indirectly — executeInvocation
|
||||
// itself does not take a signal today, but if it hangs the outer pipeline
|
||||
// timeout will bubble up through the FSM.
|
||||
void signal;
|
||||
|
||||
const raw = await core.executeInvocation(payload, rails);
|
||||
return HandoffMessage.parse(raw);
|
||||
}
|
||||
|
||||
async health(): Promise<HealthStatus> {
|
||||
try {
|
||||
await loadCore(this.opts.coreModulePath);
|
||||
return { alive: true, latencyMs: 0, message: "ok" };
|
||||
} catch (err) {
|
||||
return {
|
||||
alive: false,
|
||||
latencyMs: 0,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Nothing to clean up — the module import is cached for the lifetime
|
||||
// of the process.
|
||||
}
|
||||
}
|
||||
128
tests/transport-build.test.ts
Normal file
128
tests/transport-build.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import { buildTransports } from "../src/handoff/build.js";
|
||||
import { RailsConfig } from "../src/config/schema.js";
|
||||
|
||||
const baseConfig = (overrides: Partial<{
|
||||
transport: string;
|
||||
endpoint: string;
|
||||
agentName: string;
|
||||
}>) =>
|
||||
RailsConfig.parse({
|
||||
pipeline: { stages: ["plan", "implement", "review", "deploy"] },
|
||||
agents: {
|
||||
plan: {
|
||||
role: "plan",
|
||||
agentName: overrides.agentName ?? "harang",
|
||||
transport: overrides.transport ?? "mock",
|
||||
endpoint: overrides.endpoint ?? "",
|
||||
},
|
||||
implement: {
|
||||
role: "implement",
|
||||
agentName: "narang",
|
||||
transport: overrides.transport ?? "mock",
|
||||
endpoint: overrides.endpoint ?? "",
|
||||
},
|
||||
review: {
|
||||
role: "review",
|
||||
agentName: "darang",
|
||||
transport: overrides.transport ?? "mock",
|
||||
endpoint: overrides.endpoint ?? "",
|
||||
},
|
||||
deploy: {
|
||||
role: "deploy",
|
||||
agentName: "erang",
|
||||
transport: overrides.transport ?? "mock",
|
||||
endpoint: overrides.endpoint ?? "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const envKeys = [
|
||||
"RAILS_TRANSPORT",
|
||||
"RAILS_TRANSPORT_MODE",
|
||||
"RAILS_TRANSPORT_PLAN",
|
||||
"RAILS_TRANSPORT_IMPLEMENT",
|
||||
"RAILS_TRANSPORT_REVIEW",
|
||||
"RAILS_TRANSPORT_DEPLOY",
|
||||
"SISTER_ENDPOINT_PLAN",
|
||||
"SISTER_ENDPOINT_IMPLEMENT",
|
||||
"SISTER_ENDPOINT_REVIEW",
|
||||
"SISTER_ENDPOINT_DEPLOY",
|
||||
"RAILS_AGENT_PLAN_HOST",
|
||||
"RAILS_AGENT_IMPLEMENT_HOST",
|
||||
];
|
||||
|
||||
describe("transport builder", () => {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of envKeys) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to mock when nothing is set", () => {
|
||||
const config = baseConfig({ transport: "mock" });
|
||||
const map = buildTransports(config);
|
||||
expect(map.size).toBe(4);
|
||||
for (const [, t] of map) expect(t.name).toBe("mock");
|
||||
});
|
||||
|
||||
it("RAILS_TRANSPORT=in-process wires in-process transport everywhere", () => {
|
||||
process.env["RAILS_TRANSPORT"] = "in-process";
|
||||
const config = baseConfig({ transport: "mock" });
|
||||
const map = buildTransports(config);
|
||||
expect(map.get("plan")?.name).toBe("in-process:harang");
|
||||
expect(map.get("implement")?.name).toBe("in-process:narang");
|
||||
expect(map.get("review")?.name).toBe("in-process:darang");
|
||||
expect(map.get("deploy")?.name).toBe("in-process:erang");
|
||||
});
|
||||
|
||||
it("per-stage RAILS_TRANSPORT_REVIEW overrides global", () => {
|
||||
process.env["RAILS_TRANSPORT"] = "in-process";
|
||||
process.env["RAILS_TRANSPORT_REVIEW"] = "mock";
|
||||
const config = baseConfig({ transport: "mock" });
|
||||
const map = buildTransports(config);
|
||||
expect(map.get("plan")?.name).toBe("in-process:harang");
|
||||
expect(map.get("review")?.name).toBe("mock");
|
||||
});
|
||||
|
||||
it("http transport falls back to mock when no endpoint is configured", () => {
|
||||
process.env["RAILS_TRANSPORT"] = "http";
|
||||
const config = baseConfig({ transport: "mock" });
|
||||
const map = buildTransports(config);
|
||||
// Without endpoints, everything falls back to mock
|
||||
for (const [, t] of map) expect(t.name).toBe("mock");
|
||||
});
|
||||
|
||||
it("http transport honors SISTER_ENDPOINT_* env", () => {
|
||||
process.env["RAILS_TRANSPORT"] = "http";
|
||||
process.env["SISTER_ENDPOINT_PLAN"] = "http://plan.local:18801";
|
||||
process.env["SISTER_ENDPOINT_IMPLEMENT"] = "http://impl.local:18801";
|
||||
process.env["SISTER_ENDPOINT_REVIEW"] = "http://rev.local:18801";
|
||||
process.env["SISTER_ENDPOINT_DEPLOY"] = "http://dep.local:18801";
|
||||
const config = baseConfig({ transport: "mock" });
|
||||
const map = buildTransports(config);
|
||||
expect(map.get("plan")?.name).toBe("http:harang");
|
||||
expect(map.get("deploy")?.name).toBe("http:erang");
|
||||
});
|
||||
|
||||
it("legacy RAILS_TRANSPORT_MODE=http + RAILS_AGENT_*_HOST still works", () => {
|
||||
process.env["RAILS_TRANSPORT_MODE"] = "http";
|
||||
process.env["RAILS_AGENT_PLAN_HOST"] = "10.0.0.1";
|
||||
process.env["RAILS_AGENT_IMPLEMENT_HOST"] = "10.0.0.2";
|
||||
const config = baseConfig({ transport: "mock" });
|
||||
const map = buildTransports(config);
|
||||
expect(map.get("plan")?.name).toBe("http:harang");
|
||||
expect(map.get("implement")?.name).toBe("http:narang");
|
||||
// review/deploy have no host → fall back to mock
|
||||
expect(map.get("review")?.name).toBe("mock");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user