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:
2026-04-10 21:51:53 +09:00
parent c2e89ec5da
commit 8c123cb03a
25 changed files with 1824 additions and 232 deletions

View File

@@ -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
View 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"]

View File

@@ -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
View 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
View 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
View 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` 순으로 읽기

View 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
View 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
View 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";

View File

@@ -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";

View 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>;
}

View 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),
};
}
}
}

View 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,
});
}

View 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,
};
}
}

View 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),
};
}
}
}

View 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),
};
}
}
}

View 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)
}`,
});
}
});
});
}
}

View File

@@ -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,
},

View File

@@ -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,
});

View File

@@ -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({

View File

@@ -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>;

View File

@@ -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;
}

View 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);
}
}

View 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.
}
}

View 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");
});
});